Log Entry

Migrating WITH SECURITY_ENFORCED to WITH USER_MODE

Apr 25, 2026 · 16 min read

WITH SECURITY_ENFORCED has had a good run, but Summer '26 is the moment to stop treating it as future-safe Apex.

In API 67.0+, Apex classes that still contain WITH SECURITY_ENFORCED do not compile. The replacement is WITH USER_MODE.

That sounds like a search-and-replace job.

It is not quite that.

The short version

For many simple read queries, this is the migration:

List<Account> accounts = [
    SELECT Id, Name
    FROM Account
    WITH SECURITY_ENFORCED
];

becomes:

List<Account> accounts = [
    SELECT Id, Name
    FROM Account
    WITH USER_MODE
];

That is the easy case.

The important difference is that WITH USER_MODE is a broader access decision. It enforces object permissions, field-level security, sharing rules, and more of the query surface than WITH SECURITY_ENFORCED did.

That is why the migration is useful.

It is also why you should test it properly.

Why WITH USER_MODE is better

WITH SECURITY_ENFORCED was useful because it made Apex queries fail when selected fields or objects were not accessible to the running user.

WITH USER_MODE goes further:

  • it works for SOQL and SOSL
  • it checks fields used outside the SELECT list, such as fields in WHERE
  • it handles relationship and polymorphic query cases better
  • it applies sharing rules for the database operation
  • it lines up with the newer as user / as system DML model
  • it is the supported path for API 67.0+

That means the migration is not just syntax. It is a chance to make the data-access intent visible.

Step 1: Inventory the problem

Start with the boring search.

rg -n "WITH\\s+SECURITY_ENFORCED" force-app/main/default/classes

Then find classes already on, or about to move to, API 67.0.

rg -n "<apiVersion>67\\.0</apiVersion>" force-app/main/default/classes -g "*.cls-meta.xml"

If you want a simple CI guard after migration:

if rg -n "WITH\\s+SECURITY_ENFORCED" force-app/main/default/classes; then
  echo "WITH SECURITY_ENFORCED is not allowed in API 67+ Apex."
  exit 1
fi

That catches accidental reintroduction without adding a new dependency.

Step 2: Classify each query

Before changing code, put each hit into one of these buckets:

6 / 6 rows

User-facing readWITH USER_MODE
LWC or Aura controller readWITH USER_MODE
Search endpointWITH USER_MODE or SOSL WITH USER_MODE
Report/export for current userWITH USER_MODE
Internal platform maintenanceconsider WITH SYSTEM_MODE, but document why
Graceful UI response where inaccessible fields should be omittedconsider Security.stripInaccessible()

Most controller queries should move to WITH USER_MODE.

The risky cases are the old "helper" classes where nobody remembers whether the caller expects user-level access or elevated access.

Example 1: Basic controller query

Before:

public with sharing class AccountListController {
    @AuraEnabled(cacheable=true)
    public static List<Account> accounts() {
        return [
            SELECT Id, Name, Industry
            FROM Account
            WITH SECURITY_ENFORCED
            LIMIT 50
        ];
    }
}

After:

public with sharing class AccountListController {
    @AuraEnabled(cacheable=true)
    public static List<Account> accounts() {
        return [
            SELECT Id, Name, Industry
            FROM Account
            WITH USER_MODE
            LIMIT 50
        ];
    }
}

This is the cleanest migration. The class enforces record sharing, and the query explicitly enforces user-mode database access.

In API 67.0+, user mode is also the default for database operations, but I would still keep the clause. Explicit code is easier to review.

Example 2: The WHERE clause can now matter

This is one of the sneaky ones.

Before:

public with sharing class CandidateSearchService {
    public static List<Contact> executiveCandidates() {
        return [
            SELECT Id, Name
            FROM Contact
            WHERE Salary_Band__c = 'Executive'
            WITH SECURITY_ENFORCED
        ];
    }
}

The selected fields look harmless: Id, Name.

But the filter uses Salary_Band__c. With user mode, that field access is part of the security decision.

After:

public with sharing class CandidateSearchService {
    public static List<Contact> executiveCandidates() {
        return [
            SELECT Id, Name
            FROM Contact
            WHERE Salary_Band__c = 'Executive'
            WITH USER_MODE
        ];
    }
}

That may now fail for users who cannot access Salary_Band__c.

That is not a bad thing. It means the query was making a decision based on a field the user was not allowed to see.

Your options are:

  • grant the field access if the feature genuinely requires it
  • move the feature behind a permission set
  • redesign the filter so it uses fields the user can access
  • make the operation explicitly system-mode if it is genuinely internal

Do not hide this by catching the exception and returning misleading results.

Example 3: Relationship fields

Relationship fields are another good reason to migrate intentionally.

Before:

List<Case> cases = [
    SELECT Id, CaseNumber, Account.Name, Owner.Name
    FROM Case
    WITH SECURITY_ENFORCED
    LIMIT 100
];

After:

List<Case> cases = [
    SELECT Id, CaseNumber, Account.Name, Owner.Name
    FROM Case
    WITH USER_MODE
    LIMIT 100
];

The syntax barely changes, but your test coverage should.

Test with a user who can see the case but not necessarily every related record or field. The behaviour you care about is not "does admin get rows?" It is "does the actual feature user get the right rows, or a clear access error?"

Example 4: Dynamic SOQL

WITH SECURITY_ENFORCED often shows up in dynamic query builders too.

Before:

public with sharing class AccountSearchService {
    public static List<Account> byIndustry(String industry) {
        String soql =
            'SELECT Id, Name, Industry ' +
            'FROM Account ' +
            'WHERE Industry = :industry ' +
            'WITH SECURITY_ENFORCED';

        return Database.query(soql);
    }
}

After:

public with sharing class AccountSearchService {
    public static List<Account> byIndustry(String industry) {
        String soql =
            'SELECT Id, Name, Industry ' +
            'FROM Account ' +
            'WHERE Industry = :industry';

        return Database.query(soql, AccessLevel.USER_MODE);
    }
}

That is cleaner because the query text stays focused on the query, and the execution mode is passed as execution mode.

The same idea applies to dynamic search: use the Search method overloads that accept AccessLevel.USER_MODE where appropriate.

Example 5: SOSL gets a real path

WITH USER_MODE is not just a SOQL replacement.

List<List<SObject>> results = [
    FIND :searchTerm
    IN ALL FIELDS
    RETURNING
        Account(Id, Name),
        Contact(Id, Name, Email)
    WITH USER_MODE
];

That gives search code the same posture: current-user object access, field access, sharing-aware results.

This is useful for global search components and custom lookup components where the user expects Apex results to match what Salesforce would normally allow them to see.

Example 6: When stripInaccessible() is the better migration

Sometimes failure is the right behaviour. Sometimes graceful degradation is better.

If a UI can still render useful results without optional fields, Security.stripInaccessible() can be the cleaner path.

public with sharing class ExpenseSummaryController {
    @AuraEnabled(cacheable=true)
    public static List<Expense__c> recentExpenses() {
        List<Expense__c> rows = [
            SELECT Id, Name, Amount__c, Internal_Notes__c
            FROM Expense__c
            WITH SYSTEM_MODE
            ORDER BY CreatedDate DESC
            LIMIT 20
        ];

        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.READABLE,
            rows
        );

        return (List<Expense__c>) decision.getRecords();
    }
}

This pattern should make you pause.

It intentionally reads in system mode and then strips inaccessible fields before returning data. That can be valid, but I would only use it where the product behaviour really wants partial results instead of an access error.

For most simple controller reads, prefer WITH USER_MODE.

Example 7: Do not forget the write after the read

Many old WITH SECURITY_ENFORCED queries are followed by DML.

Before:

public with sharing class CasePriorityService {
    public static void markEscalated(Id caseId) {
        Case c = [
            SELECT Id, Priority
            FROM Case
            WHERE Id = :caseId
            WITH SECURITY_ENFORCED
            LIMIT 1
        ];

        c.Priority = 'High';
        update c;
    }
}

After:

public with sharing class CasePriorityService {
    public static void markEscalated(Id caseId) {
        Case c = [
            SELECT Id, Priority
            FROM Case
            WHERE Id = :caseId
            WITH USER_MODE
            LIMIT 1
        ];

        c.Priority = 'High';
        update as user c;
    }
}

This is the migration people miss.

WITH USER_MODE secures the read. update as user secures the write.

If the write should be elevated, make that explicit:

update as system c;

Then make sure the method name, class name, and tests explain why.

Exception handling

Do not turn security exceptions into empty lists unless an empty list is genuinely correct.

This is usually bad:

try {
    return [
        SELECT Id, Name, Sensitive_Field__c
        FROM Account
        WITH USER_MODE
    ];
} catch (QueryException e) {
    return new List<Account>();
}

The caller cannot tell the difference between "there are no accounts" and "your access is wrong".

Prefer an explicit user-facing error:

try {
    return [
        SELECT Id, Name, Sensitive_Field__c
        FROM Account
        WITH USER_MODE
    ];
} catch (QueryException e) {
    throw new AuraHandledException(
        'You do not have access to one or more fields required by this view.'
    );
}

That gives the user a truthful failure and gives support a sensible place to start.

Test plan I would use

For each migrated feature, test at least these paths:

  • admin or power user still gets the expected result
  • normal feature user gets the expected result
  • user without one selected field gets a clear failure or stripped field, depending on the design
  • user without access to a filter field gets the expected failure
  • user without record access gets no row or a clear failure, depending on the feature
  • write paths use as user or as system intentionally

The non-admin tests are the real migration tests.

Admin-only testing will miss most of the point.

Migration checklist

Use this as the review checklist before moving a class to API 67.0+.

  • Replace every WITH SECURITY_ENFORCED.
  • Prefer WITH USER_MODE for user-facing SOQL.
  • Use AccessLevel.USER_MODE for dynamic SOQL and dynamic SOSL.
  • Add as user or as system to nearby DML where the access mode matters.
  • Check fields in WHERE, ORDER BY, relationship paths, and subqueries.
  • Decide whether hard failure or graceful degradation is the right product behaviour.
  • Use Security.stripInaccessible() only when partial results are deliberate.
  • Test with realistic profiles and permission sets, not just admins.
  • Keep WITH SYSTEM_MODE and as system rare, explicit, and documented by tests.

Source notes

Final take

The migration is worth doing before API 67.0 forces the issue.

Most changes are small, but the value is not the syntax. The value is that every query starts saying what security context it expects.

That makes Apex easier to review, easier to test, and harder to accidentally turn into a data leak.