XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

Migrating WITH SECURITY_ENFORCED to WITH USER_MODE

Apr 25, 2026 · 6 min read

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.

Sources