Most ServiceNow access bugs come from a wrong mental model, not a wrong role. Someone hides a field with a UI policy and calls it secured. Someone adds a read rule on task and is surprised it does nothing for incidents. Someone tests the change as admin and ships it. Every one of those mistakes disappears once you know how the platform actually picks and evaluates access control rules, which is what interviewers are probing when they ask “how do ACLs work?”
What a single ACL checks
An access control rule secures one object for one operation. For records, the object is a table name plus an optional field, written incident (the table), incident.u_home_phone (one field) or with wildcards such as *.*. The operation is read, write, create or delete for most work, with extras like list_edit, report_on and execute (used for client-callable script includes and REST endpoints).
Inside one ACL there can be up to three kinds of requirement: required roles, a data condition built in the condition builder, and a script that sets answer. The user has to satisfy all of them. The roles list is the one exception to strictness: if you list several roles, having any one of them is enough, and an empty roles list counts as passed. Recent releases also add security attributes (such as “user is authenticated”) as a fourth kind of requirement, combined the same way.
Two details matter in practice. First, most ACLs have Admin overrides ticked, which means a user with the admin role passes that rule without evaluating it. Second, current releases deny by default when an ACL is empty or invalid (no role, condition, attribute or script, or a script that is just answer = true), so a rule you created in a hurry can lock everyone out rather than let everyone in.
Table first, then field, most specific first
For a record operation, the user must pass a table-level check and then a field-level check. If they fail the table ACLs, every field on that record is denied, no matter how generous the field rules are. The documented matching order for tables is:
- The table itself, for example
incident - Its parent tables, for example
task - The wildcard
*
Field ACLs are searched in this order: incident.number, task.number, *.number, incident.*, task.*, *.*.
The important word is first. The platform uses the ACLs at the most specific point in that order that has any matching rules. If two or more ACLs match at that same point, passing any one of them is enough. Less specific rules further down the list aren’t consulted. That’s what makes a field ACL useful at all: if failing incident.u_home_phone let the engine fall through to a permissive *.*, you could never restrict a single field.
The same rule explains a classic surprise. Suppose task has a read ACL and incident has none. Incidents are governed by the task rule. The day someone creates any read ACL on incident, the task rule stops applying to incidents, and access can widen or narrow without anyone touching the task rule.
A runnable model of the decision
No instance is needed to see the logic. The script below is a simplified model in plain Node.js (no Glide APIs). It encodes the table chain, the field order, AND inside one ACL, OR between ACLs at the same point, and admin override. The ACL set is invented for illustration, not copied from a base instance: incidents are readable by itil or by the caller, a custom u_home_phone field needs a vip_desk role, and a *.* rule lets internal users read ordinary fields.
const parents = { incident: 'task', task: null };
const acls = [
{ type: 'allow', table: 'task', field: null, roles: ['itil'] },
{ type: 'allow', table: 'incident', field: null, roles: ['itil'] },
{ type: 'allow', table: 'incident', field: null, roles: [], cond: (u, r) => r.caller === u.name },
{ type: 'allow', table: 'incident', field: 'u_home_phone', roles: ['vip_desk'] },
{ type: 'allow', table: '*', field: '*', roles: ['snc_internal'] },
];
function chain(table) { const out = []; for (let t = table; t; t = parents[t]) out.push(t); return [...out, '*']; }
function passes(acl, user, rec) {
if (acl.adminOverrides !== false && user.roles.includes('admin')) return true;
const roleOk = acl.roles.length === 0 || acl.roles.some((r) => user.roles.includes(r));
const condOk = acl.cond ? acl.cond(user, rec) : true;
return roleOk && condOk;
}
function decide(candidates, user, rec) {
const denyUnless = acls.filter((a) => a.type === 'deny' && candidates.some(([t, f]) => a.table === t && a.field === f));
for (const a of denyUnless) if (!passes(a, user, rec)) return 'denied (Deny-Unless failed)';
for (const [t, f] of candidates) {
const here = acls.filter((a) => a.type === 'allow' && a.table === t && a.field === f);
if (here.length === 0) continue;
const label = f ? `${t}.${f}` : t;
return here.some((a) => passes(a, user, rec)) ? `allowed by ${label}` : `denied at ${label}`;
}
return 'allowed (no matching ACL)';
}
function canRead(user, table, rec, field) {
const t = chain(table);
const tableResult = decide(t.map((x) => [x, null]), user, rec);
if (!tableResult.startsWith('allowed')) return `record ${tableResult}`;
return decide([...t.map((x) => [x, field]), ...t.map((x) => [x, '*'])], user, rec);
}
const inc = { number: 'INC0010042', caller: 'dana', company: 'Acme' };
const users = [
{ name: 'beth', roles: ['itil', 'snc_internal'], company: 'Acme' },
{ name: 'carl', roles: ['itil', 'vip_desk', 'snc_internal'], company: 'Acme' },
{ name: 'dana', roles: ['snc_internal'], company: 'Acme' },
{ name: 'erin', roles: ['snc_internal'], company: 'Acme' },
{ name: 'admin', roles: ['admin'], company: 'Acme' },
];
for (const u of users)
for (const field of ['short_description', 'u_home_phone'])
console.log(`${u.name.padEnd(5)} ${field.padEnd(17)} ${canRead(u, 'incident', inc, field)}`);Running it for those five users against INC0010042, whose caller is dana, prints:
beth short_description allowed by *.*
beth u_home_phone denied at incident.u_home_phone
carl short_description allowed by *.*
carl u_home_phone allowed by incident.u_home_phone
dana short_description allowed by *.*
dana u_home_phone denied at incident.u_home_phone
erin short_description record denied at incident
erin u_home_phone record denied at incident
admin short_description allowed by *.*
admin u_home_phone allowed by incident.u_home_phoneRead it line by line. Beth is an itil agent: she passes the table check and reads normal fields, but the phone field is decided at incident.u_home_phone and she lacks vip_desk. Dana has no roles but is the caller, so the second incident ACL lets her in; she still can’t see the phone number. Erin has no relationship to the record and fails both incident rules. Note that the task ACL never came into play for her, because incident already had matching rules. Admin sees everything, which is exactly why an admin test proves nothing.
Deny-Unless rules
Newer releases add a decision type to each ACL. The traditional kind is Allow-If. A Deny-Unless ACL works the other way round: the user is denied unless its requirements are met, Deny-Unless rules are evaluated before Allow-If rules, and when several Deny-Unless rules match, the user has to pass all of them. Passing a Deny-Unless rule doesn’t grant anything by itself; an Allow-If rule still has to allow the operation.
That makes Deny-Unless a good fit for cross-cutting fences such as “only within the user’s own company”. Adding one to the model:
acls.push({ type: 'deny', table: 'incident', field: null, roles: [], cond: (u, r) => u.company === r.company });
const globex = { ...inc, number: 'INC0010043', company: 'Globex' };
console.log('carl short_description', canRead(users[1], 'incident', globex, 'short_description'));carl short_description record denied (Deny-Unless failed)Carl has every role he needs, but the Globex incident fails the fence, so he’s out. Without a Deny-Unless rule you would have to add the company condition to every Allow-If rule on the table and hope nobody adds a new one without it.
Lists, queries and rows that vanish
List views add a twist. Before the query runs, the instance checks field ACLs using roles only, since it has no record data yet. After the query, it evaluates the full rules, conditions and scripts included, for each returned row. Rows the user can’t read are removed, and the list shows a message like “Number of rows removed from this list by Security constraints: 20”. Paging gets odd as a result: page one might show 3 rows out of a nominal 20.
The usual fix is a before query business rule that adds the same restriction to the query itself (for example current.addQuery('company', gs.getUser().getCompanyID())), so the database only returns rows the ACL would allow anyway. Keep the ACL too: the query rule shapes lists, the ACL is the security boundary.
Server code doesn’t ask
ACLs protect sessions: forms, lists, the REST Table API, exports. A server script using GlideRecord doesn’t evaluate them. A business rule, a scheduled job or a Scripted REST resource can read u_home_phone for anybody, and if that Scripted REST resource returns the value, you’ve built a bypass. Use GlideRecordSecure (or check canRead() on the record and field) whenever server code acts on behalf of the calling user.
To test, impersonate representative users rather than relying on admin, check both the allowed and the denied cases, and repeat the check through the Table API with a non-admin integration account. The ACL debugging tools (Debug Security Rules, and the newer Access Analyzer) show which rule decided each check, which is much faster than guessing.
Interview practice
An itil agent reports they can no longer open any incident, though they still see problems and changes. Yesterday a developer added a read ACL on incident with a condition assigned_to is (dynamic) Me. Nothing on task changed. What happened, and how would you fix it?
Answer
Before yesterday, incidents had no table-level read ACL of their own, so the task read rule (which the agent passes through itil) decided access. The new incident ACL is now the most specific match for incident reads, so the task rule is no longer consulted for incidents. The agent passes the new rule only on incidents assigned to them, so everything else is denied. Problems and changes still fall back to task, which is why they work.
The fix is to model the intended policy at the incident level explicitly: add a second incident read ACL that requires itil (ACLs at the same point are ORed, so either rule grants access), or fold both cases into one rule. Then retest by impersonating an agent, a caller and an unrelated user, and repeat with the Table API so the API path matches the form.
Related reading
- ServiceNow interview questions
- ServiceNow multiple-choice questions
- ServiceNow ACL Scripting and Access, on writing script conditions that stay cheap
- ServiceNow Dictionary and Field Audit, for the field definitions ACLs attach to
- ACL types (ServiceNow documentation), which lists the table and field matching order