I was tidying a demo org one afternoon — my Urla Shoes practice org, a fictional shoe retailer living in a real Developer Edition org — and I did something small and boring: I copied a query out of one Apex class into a new one, to reuse it.
Same three lines. Same object, same fields, same WHERE clause. I did not change a character.
The two classes did not behave the same way. In one, a field the running user could not see came back happily. In the other, the query refused. It took me longer than I would like to admit to find the cause, because I was looking in the code, and the cause was not in the code. It was in the little XML file sitting beside it.
This post is about that file, and about a default that most beginners are taught once, in passing, and then quietly get wrong for years.
What “system mode” actually means
Apex has two modes of running a data operation.
In system mode, the code executes with the platform’s own reach rather than the running user’s. Object permissions — can this user see Contacts at all? — are not checked. Field-level security — can this user see Salary__c? — is not checked. The query returns what the query asked for.
In user mode, those checks are applied at the moment of access. If the running user may not read a field named in the statement, the statement fails, loudly and immediately, instead of quietly handing you the value.
Neither is “the secure one” in an absolute sense. System mode exists for real reasons — audit fields that must be stamped for users who cannot edit them, integration logic that touches objects no human profile touches. The problem is not that system mode exists. It is which one you get when you do not say.
The default lives on the file, not on the org
Here is the fact I want you to keep.
In Apex written below API version 67.0, a plain SOQL query runs in system mode. Field-level security and object CRUD are not enforced by the Apex layer. That has been true for most of Apex’s life, and it is why “Apex ignores your security model” is one of the most repeated sentences in Salesforce teaching.
At API version 67.0 and above, the default flipped. Plain operations run in user mode.
Now read those two paragraphs again and notice what they do not say. They do not describe an org setting. They do not describe a release you can be on or not on. Every Apex class carries its own API version, stamped in its metadata file — the .cls-meta.xml that sits next to the .cls. That number is per file.
Which means an org can contain two classes, running the identical query, with different effective access. Not because anyone wrote different code. Purely because of the version stamped on each file. That is exactly what had happened to me: my new class was created at the current version, my old one had been sitting at an older one for a year, and the copied query inherited a different rulebook on each side.
The security behaviour of your query is not a property of your org. It is a property of the file the query lives in.
Why a mixed-version org is the normal case
You might hope this sorts itself out over time. It does not. Apex classes do not upgrade their API version on their own: a class written three years ago keeps its stamp until a human changes it, and a version bump can alter other behaviour in that class too, which is precisely why nobody does it casually. Managed packages bring their own versions with them.
So the realistic state of a working org is a spread of versions across hundreds of files, with the newest work at the top and a long, quiet tail underneath. The tail is where most of the data access usually lives, because the oldest code is the code that has been doing the job the longest. Stop reasoning about “my org’s default”, and read the stamp.
Two words that settle the question
The reliable fix is to stop depending on the default at all, and say what you mean on the statement itself.
For SOQL, that is WITH USER_MODE:
List<Contact> contacts = [
SELECT Id, Name, Email
FROM Contact
WHERE AccountId = :accountId
WITH USER_MODE
];
For the Database methods, it is AccessLevel.USER_MODE:
Database.update(contactsToSave, AccessLevel.USER_MODE);
Both do the same job: the platform enforces the running user’s object and field permissions at the moment of access, and throws if they are not satisfied.
I prefer this to relying on any default — old or new — for one reason: an explicit statement is readable. A colleague reviewing this query can see the intent without opening a metadata file. So can you, in eighteen months.
stripInaccessible is a different tool, not a synonym
You will meet Security.stripInaccessible in older codebases and in a lot of advice, and it is easy to file it mentally next to USER_MODE. They are not the same shape at all, and the difference matters.
List<Contact> contacts = [SELECT Id, Salary__c FROM Contact];
SObjectAccessDecision decision =
Security.stripInaccessible(AccessType.READABLE, contacts);
List<Contact> safe = decision.getRecords();
Read the order of events. The query already ran. The data already came back, in full, into your transaction. stripInaccessible then removes the fields the user may not see from the records you are holding.
USER_MODE refuses the read. stripInaccessible tidies up after it.
That distinction is small in a screen controller and large everywhere else. If the values touched anything — a log line, a variable you passed onward, an output your method returns — before the stripping happened, the stripping came too late. Cleaning up afterwards only works if nothing happened in between, and that is a claim about the whole method, not about one line.
While you are in there: the other axis
One clarification, because these two get folded together constantly. Field-level security and CRUD decide which fields and objects you may touch. Sharing — the with sharing declaration you have seen at the top of classes — decides which records come back.
They are separate axes, and a class can be strict on one while wide open on the other. A class declared with sharing will happily read a field the user is not allowed to see, because sharing was never asked about fields. If you set one dial and assumed the other followed, you are in extremely normal company; I have written about that mismatch elsewhere on this blog and I still catch myself.
Your next step
Pick one Apex class in your org that reads customer data. Do three things, in this order, and they will take you about ten minutes:
- Open its meta file and read the
apiVersion. Just look. Whatever number is there is the rulebook that class is playing by. - Find every SOQL statement and every DML statement in it. Count them.
- Add
WITH USER_MODEandAccessLevel.USER_MODEto each one, and run your tests.
If the tests still pass, you have made the class honest at no cost. If a test fails, you have just learned something true about your org that a green build was hiding. Both outcomes are worth the ten minutes — and the second one is worth considerably more.