[readonly] markdown buffer
Migrating WITH SECURITY_ENFORCED to WITH USER_MODE
In API 67.0 and later, Apex containing WITH SECURITY_ENFORCED does not compile. Most queries should move to WITH USER_MODE, but this is more than a search and replace: user mode checks a broader part of the operation.
A five-step migration
1. Find and classify every query
Start with the source and the API versions about to move:
rg -n "WITH\s+SECURITY_ENFORCED" force-app/main/default/classes
rg -n "<apiVersion>67\.0</apiVersion>" \
force-app/main/default/classes -g "*.cls-meta.xml"
User-facing controllers, searches and exports normally want user mode. Internal maintenance may genuinely need system mode, but that should be an explicit, documented exception.
2. Replace the straightforward reads
For a normal controller query, the syntax change is small:
return [
SELECT Id, Name, Industry
FROM Account
WITH USER_MODE
LIMIT 50
];
User mode enforces object and field access and applies sharing to the database operation. Keep the clause explicit even where the API default would produce the same behaviour; reviewers can see the intended boundary.
3. Inspect the whole query surface
Do not look only at selected fields. User mode also makes access to filters, ordering, relationships and subqueries part of the decision.
SELECT Id, Name
FROM Contact
WHERE Salary_Band__c = 'Executive'
WITH USER_MODE
A user without access to Salary_Band__c may now receive an error. That is useful information: the feature was making a decision with a field the user could not see. Grant intentional access, redesign the feature or explicitly elevate a genuinely internal operation. Do not disguise the error as “no records”.
4. Use execution modes for dynamic queries
For dynamic SOQL, keep the access decision outside the query string:
String soql =
'SELECT Id, Name, Industry ' +
'FROM Account ' +
'WHERE Industry = :industry';
return Database.query(soql, AccessLevel.USER_MODE);
Use the corresponding user-mode path for SOSL. Bind variables and allowlisted identifiers are still required; an execution mode is not an injection defence.
5. Review the write that follows
Securing the read does not automatically explain the write. Make nearby DML equally clear:
Case caseRecord = [
SELECT Id, Priority
FROM Case
WHERE Id = :caseId
WITH USER_MODE
LIMIT 1
];
caseRecord.Priority = 'High';
update as user caseRecord;
If the operation must be elevated, use as system deliberately and cover the reason in tests.
Failure or graceful degradation?
WITH USER_MODE is a good default when missing access means the feature cannot work correctly. Return a clear user-facing error rather than an empty list that looks like valid data.
Security.stripInaccessible() is useful when the product can intentionally show partial results without optional fields. It should be a product decision, not a way to suppress unexpected security failures.
Test the people who are not admins
For each migrated feature, cover:
- the normal feature user
- a user missing one selected field
- a user missing a filter or relationship field
- a user without access to a relevant record
- both user-mode and intentionally elevated writes
Admin-only testing misses the point of the migration.
The syntax update is simple. The real work is making each read and write state its security context, then proving the feature behaves truthfully when a user has less access than its author.