Ch. 17 · ServiceNow

ServiceNow ACLs and Record Access Decisions

How ServiceNow decides record access: table ACLs before field ACLs, the most specific match wins, and why server scripts skip the check.

~7 min readintermediateupdated Oct 12, 2026

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:

  1. The table itself, for example incident
  2. Its parent tables, for example task
  3. 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)}`);
JavaScript

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_phone
Text

Read 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'));
JavaScript
carl  short_description record denied (Deny-Unless failed)
Text

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.

More in ServiceNow

read ✓ServiceNow · hard

ServiceNow ACL Scripting and Access

When an ACL really needs a script, how to write one that is cheap and null-safe on every row, and how to prove it works by testing as the user.

~7 min readread →
read ✓ServiceNow · hard

ServiceNow Caching and Invalidation

What a ServiceNow instance caches on each node, why a system property change triggers a costly flush, and where changing settings belong.

~7 min readread →
read ✓ServiceNow · mid

ServiceNow Dictionary and Field Audit

How dictionary entries and per-table overrides define a field, how auditing writes to sys_audit, and which fields you should keep out of history.

~6 min readread →
esc