Most security problems in Salesforce implementations are not exotic platform vulnerabilities. They are bad habits we all know we should avoid, and still fall into from time to time. In this article I go through five specific bad practices from real projects to show why they are a problem and how to do it better.
In this article:
- Client-side logic: the attacker is your own user
- Sensitive data exposure: data flows where it should not
- CRUD/FLS: hidden ≠ forbidden
- Storing secrets: the attacker can be your own admin
- Admin permissions for everyone: the attacker is whoever steals your account
- When will it be your org’s turn?
- In closing
Client-side logic: the attacker is your own user
Take a simple LWC component on Opportunity: the user sets a discount in it. A regular user may enter up to 10 %, someone with a special permission may enter more. The component calls an Apex method that saves the discount, and the limit check is written in the component's JavaScript.
It looks reasonable. Except the whole check runs in the user's browser. All it takes is to open DevTools, find the right JavaScript in the loaded sources, rewrite the condition from 10 to 100, and save a discount I have no right to. The server dutifully writes it, because the Apex method has no check of its own.
Basic rule: anything that happens on the client side is by definition outside our control. JavaScript runs on someone else's machine, and that person has full power over it.
You may object that browsers have plenty of security mechanisms: Content Security Policy, CORS, SameSite cookies, CSRF tokens, Subresource Integrity. They do. But all of them protect the user against an attack from outside, not your server against an attack from the user. They work only as long as the user “plays by the rules”. And the user does not have to.
Second objection: “A regular user does not do this, or would not know how.” Two counterarguments:
- A regular user can be technically skilled. Years ago I “fixed” time reporting in our internal CRM this way, because it would not let me log work a month back. And within our company I was a completely ordinary user.
- Salesforce is also used by companies full of developers. There, a “regular user” is someone who opens DevTools before finishing their morning coffee.
- And if they are not: with an AI assistant, even a non-technical user gets around client-side validation. Practically every language model will happily help.
Where to watch out? Practically everywhere something runs in the browser: LWC JavaScript and markup (for example lwc:if), Aura, any third-party library, CSS tricks such as display:none. In Screen Flows, input validation and visibility on screen elements also run client-side, and can be bypassed in exactly the same way. Visualforce is an interesting exception: it renders on the server, so the browser never receives the conditional content at all.
Bottom line: client-side logic is UX, not security. Every check that matters has to be enforced by the server.
Sensitive data exposure: data flows where it should not
Most orgs hold data that is more sensitive than the rest: national ID numbers, payment card numbers, but also something as ordinary as a client's name, because in Europe GDPR makes that personal data too. Larger organizations usually have a full classification for it: every field is assigned a sensitivity category and treated accordingly. Typically in two ways: restricted read permissions (whoever does not need the client's balance has no access to the field) and Shield Platform Encryption, meaning encryption of the data in the database.
But both protections can be bypassed unintentionally, straight in the configuration or in the code. These are the bad habits I see almost daily:
Formula fields ignore Field-Level Security. A formula is calculated on the fly when the record is read, and FLS on the source fields is not evaluated. If you use a protected field in a formula and make that formula available to everyone, you have just effectively cancelled the read permissions on the source field.
Copying values into other fields. A real example from practice: the client's name is encrypted, but the task subject is not. A single innocent line of code
task.Subject = 'Call: ' + account.Name;and the sensitive value sits in an unencrypted, widely readable field. The same applies to case description (you would be surprised what people write in there) and to debug logs: you have integrations, a logging framework, you log payloads. The payload contains real client data, half the project has access to the logs, and they may well not be encrypted.
Bottom line: data sensitivity is not inherited automatically. Watch where the values flow: formulas, copies, logs, because protecting the source field protects only the source field.
CRUD/FLS: hidden ≠ forbidden
A model situation (a simplified version of something we dealt with in practice): the user is supposed to edit Opportunity exclusively through quick actions and screen flows. Buttons such as Hand Over, Close, Edit, behind which sits a controlled process, integrations, validations. We removed the standard Edit button from the interface, because for a process controlled like this the standard edit form is unusable.
The question is: how do you set up CRUD and FLS? There are two options, and both have a catch.
Option 1: we grant edit permissions. The documentation and common sense both say: the user does edit the data, so they should have edit permissions. The flow runs in user context and the system handles permissions for us. What can go wrong? You did hide the standard edit form from the layout, but try pressing E on the record page and it pops up. And even if you lock the fields on the layout, the layout is only one of many routes to the data: API access (Data Loader, integrations) knows nothing about layouts, and neither does inline edit in a list view. Whoever has edit permissions can edit. The only question is by which route.
Option 2: we do not grant edit permissions. We create a custom permission (for example CanEditOpportunity), the flow runs in system context and checks the permission itself in a decision element. The hole from option 1 is closed. The price? You can no longer use the standard edit or create form anywhere on the object. Everything has to be custom. And that applies to other record types of the same object as well: you cannot say “this record type controlled, that one open”. Within a single object it is a one-way ticket.
There is no universally correct answer, but there is a conscious decision. In practice we use a mix: where the logic is complex and the process has to be controlled, we go with option 2 and build our own permission model on top of the object. On simpler objects we leave the edit permissions in place and accept that the UI is guidance, not a barrier. The key is not to believe that a hidden button forbids anything. Hidden ≠ forbidden. This principle is really just the first chapter generalized from the UI to the entire permission model.
Storing secrets: the attacker can be your own admin
A classic situation: an Apex callout to an external service, with a secret key in the X-API-Key header. Anyone who knows that key can call the service and pose as your org. Where do you store it? Ordered from worst to best:
- Configuration SObject (ApiKey__c) – data available through a report, a SOQL query, an export. No.
- Custom Label – a favorite “configuration” ten or fifteen years ago, always visible and to everyone. No.
- Custom Settings / Custom Metadata Types – somewhat better, read permissions can be set, but an admin simply sees the value in Setup. And a key that identifies your org to a partner should ideally not be known even to the admin.
- Managed package + protected custom settings/metadata – more interesting: protected values cannot be pulled out of the package from the outside. But if you need the key in code, the package has to expose some interface. A getter along the lines of “give me the key” can be called by anyone from the developer console.
- Named Credentials / External Credentials – the right answer. Once you store the value, Salesforce will never show it to you again, but it can use it. In code you reference a merge field (
{!$Credential.Partner_API_EC.api_key}) and the real value is filled in at the layer where the request leaves Salesforce. You will not see it in a debug log, and not even a mock in a test class will extract it. This is how you protect a secret even from your own admin.
A practical bonus on the process side: when you deal with versioning, version the service call, not the key. The key belongs in the manual deployment steps – operations administrators enter it, and the developer never has to see it.
What if I need to call an external API from JavaScript?
The same problem as in the first chapter applies here: JavaScript runs on the user's machine, so a key sent to JavaScript is a key handed to the user. Options, best first:
- Let the user authorize themselves. If the call happens in the user's context, the service can legitimately know who they are. They log in under their own name, through SSO, or a client certificate on a managed device verifies them. No shared secret exists.
- Proxy through Apex. The client does not call the API directly. It calls Salesforce, Apex forwards the request, and the key is attached only on the server. The key never leaves Salesforce.
- Last resort: an encrypted token through the client. Sometimes there is no other way (we ran into this with WebSockets, which Apex does not support). The key is encrypted and the client only carries it across. Watch out for a replay attack though: the attacker does not need to know the content, capturing the encrypted message and sending it again is enough. Mitigations: a timestamp and short token validity, binding the token to the specific content of the message (the client can then repeat only that one specific call and nothing else), anomaly detection such as a change of IP address. There is no way to solve this 100 %, which is why it is a last resort, not a first-choice pattern.
Admin permissions for everyone: the attacker is whoever steals your account
A small survey you can try on your own project: Do you have access to production? Do you have admin there? And what do you need it for?
The most common answer is “I need it for debugging”. The counter-question: do I need it, or is it just more convenient? And if I really do need it, do I really need Customize Application for debugging? Manage Encryption Keys? Modify All Data? Manage Users? The admin profile is a bundle of dozens of powerful permissions, of which a specific task typically needs only a fraction.
And now the important part: would you stake your life on nobody ever stealing your account? The reality of recent years says that nobody can promise that. Compromise today comes even from official sources you trust. Just a few examples from supply chain attacks: the tj-actions/changed-files package used in 23,000+ repositories (March 2025, AWS keys and tokens leaked), the Shai-Hulud campaign with trojanized versions of 18 widely used npm packages including debug and chalk (September 2025, the first batch had over 2.5 million downloads), a compromised Axios pulled from the registry within three hours. Even that window was enough for 135+ confirmed cases of communication with the attacker's infrastructure (March 2026), or a credential stealer in SAP packages on PyPI (April 2026). Does that mean you should stop updating? Absolutely not. Not updating is worse, zero-day vulnerabilities are merciless. But we have to admit that “I behave responsibly” simply is not enough.
You will not prevent account theft with complete certainty. What you do fully control is the blast radius: what an attacker can actually do with your stolen account. What they steal, what they change, what they install. That is exactly what the Principle of Least Privilege is about: permissions only for what I strictly need for my work, and nothing more. In practice it is often inconvenient and unpopular. Yet it is also in your own personal interest: I do not have production access myself. Yes, it is a nuisance at times. But when someone compromises my account, I am in the clear.
One possible solution is just-in-time elevation (PIM and similar). You request a sensitive permission, you get it for a limited time, then the system takes it away again. On smaller projects such a mechanism can be overkill, but the principles hold. Start by not handing admin to everyone automatically.
When will it be your org's turn?
Here are three real campaigns from the last twelve months, all targeting Salesforce. Note that none of them exploited a platform vulnerability. All of them harvested exactly what this article is about.
ShinyHunters vishing – UNC6040 (June 2025). The attackers called employees posing as IT support and walked them through installing a modified version of the Salesforce Data Loader, which the victims authorized themselves through OAuth. The tool then exfiltrated CRM data; Google confirmed a leak of roughly 2.55 million records. Pure social engineering, and the scale of the damage was set by the blast radius of the accounts that fell for it (chapter 5).
SalesLoft / Drift OAuth heist – UNC6395 (August 2025). Using stolen OAuth tokens from a single third-party integration, the attackers gained access to more than 700 Salesforce orgs. And that was not enough for them: they deliberately queried Cases and Accounts and harvested plaintext credentials stored directly in the data – AWS keys, Snowflake tokens, passwords in support tickets. Exactly the case descriptions and badly stored secrets from chapters 2 and 4. Among the victims were Cloudflare, Google, Palo Alto Networks, Proofpoint and Zscaler, companies where you would expect security to be sorted.
ShinyHunters Experience Cloud (from September 2025, still running). An automated attack on badly configured guest user profiles in Experience Cloud: the attackers modified the publicly available audit tool AuraInspector and scan /s/sfsites/aura endpoints at scale. Once they found a public site, they started calling server-side methods (see chapters 1 and 3). An estimated 300 to 400 companies were hit, including Snowflake, LastPass, Okta and the European Commission. Many of them from the cybersecurity industry itself.
The apparent gulf between “a curious user with DevTools” and “a professional attack group” does not actually exist. An external attacker first obtains an identity in some way – a stolen account, an OAuth token, a guest user on a public site. From that moment on they are exactly the “own user” that client-side checks, hidden buttons and custom labels will not protect you from. That is why the two are connected.
In closing
None of this is rocket science: server-side validation, tracking the flow of sensitive data, a conscious decision about CRUD/FLS, credentials instead of custom labels, least privilege. We all know it. The difference between an org that ends up in the news one day and an org that does not is not whether its team knows this. It is whether they act on it even when the deadline is burning and “this way will be faster”.
An attacker can steal your account, you can make it harder for them, but nobody has yet figured out how to prevent it 100 %. But what the blast radius will be, that is entirely up to you.