Ch. 17 · ServiceNow

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 readadvancedupdated Oct 19, 2026

Most ServiceNow access rules don’t need code. “Users with itil can read incidents” is a role. “Anyone can read an active knowledge article” is a condition. The script field on an ACL exists for the rules that need logic, such as “an HR case is readable by the person it’s about and by members of its assignment group”. That’s also where interviewers like to probe, because a scripted ACL is security code that runs on every record access, and small mistakes there either leak data or slow every list in the instance.

This note assumes you already know how the platform picks which ACLs apply (table before field, most specific match first). The focus here is the script itself: when to write one, how to write it so it’s cheap and correct, and how to prove it does what you think.

Roles, condition, script: all three must pass

An Allow-If ACL record has three requirement areas: required roles, a condition built in the condition builder, and an optional script. For a single ACL to grant access, the user has to satisfy every area you filled in. If you list two roles, having either one satisfies the role part. If several ACLs match the same object at the same level, passing any one of them is enough.

That “and inside, or across” behaviour is the first thing to get right, because people often put logic in the script that the other two parts already do better. A role check in the script (gs.hasRole('hr_agent')) hides the requirement from anyone reading the ACL list, and it can’t be reported on. A condition like active = true is visible, cheap, and can be applied to queries. So the rule of thumb is: roles in the role list, simple field comparisons in the condition, and only the leftover logic in the script.

Deny-Unless ACLs, available on recent releases, flip the logic. They’re evaluated before Allow-If ACLs, and if their requirements aren’t met the user is denied regardless of any Allow-If rule. Scripts work the same way in both kinds; only the meaning of a false result changes.

How the script reports its answer

The script sets a variable called answer to true or false. The platform evaluates the script with current bound to the record being checked and the session user available through gs. If the script ends without setting answer to true, the requirement fails, which means a script that throws an exception or takes a branch you forgot also denies access. Failing closed is safe, but set answer on every path anyway so a denial is easy to explain.

Here’s the HR case rule written badly, then written properly. The examples are illustrative; no instance was available while writing them, so check field names against your own tables.

// Read ACL on sn_hr_core_case, version 1: works in a demo, hurts in production
var gr = new GlideRecord('sys_user_grmember');
gr.addQuery('user', gs.getUserID());
gr.addQuery('group', current.assignment_group);
gr.query();
answer = current.opened_for == gs.getUserID() || gr.hasNext();
JavaScript
// Read ACL on sn_hr_core_case, version 2
answer = (function () {
  var me = gs.getUserID();
  if (current.getValue('opened_for') === me) return true;

  var group = current.getValue('assignment_group');
  if (!group) return false;               // unassigned cases: only the subject sees them
  return gs.getUser().isMemberOf(group);  // no hand-written membership query per row
})();
JavaScript

Three differences matter. Version 2 reads raw sys_id strings with getValue() instead of comparing a GlideElement object with ==, which happens to work through type coercion but breaks the moment someone switches to ===. It handles an empty assignment_group explicitly instead of querying for a group called nothing. And it doesn’t run its own GlideRecord query on every evaluation.

The cost: one evaluation per row

Interviewers often ask why a slow ACL is worse than a slow business rule. A business rule runs when a record is saved. A read ACL runs whenever a record is read, and a list view reads every row on the page. If a field-level ACL also uses a script, that runs for the cells it protects as well.

I modelled the two versions in Node, with a stub standing in for the membership query and a page where a quarter of the cases were opened for the current user and a third belong to their group:

const perRowQuery = (cur) => cur.opened_for === me.id || membershipTable().includes(cur.assignment_group);
const sessionGroups = new Set(me.groups);
const noQuery = (cur) => cur.opened_for === me.id || sessionGroups.has(cur.assignment_group);
JavaScript
20 rows, per-row query: visible=10 extra queries=15
20 rows, session groups: visible=10 extra queries=0
100 rows, per-row query: visible=50 extra queries=75
100 rows, session groups: visible=50 extra queries=0
Text

The short-circuit on opened_for saves a query for a quarter of the rows, but every other row still pays for one. Raise the page size to 100 and you’ve added 75 database round trips to one list load, for one user. Multiply by a service desk refreshing lists all morning and the ACL shows up in slow transaction logs. The fix is to avoid per-row queries entirely: use what the session already knows about the user, or move the work into a condition the database can evaluate.

Interview tip

If you truly need data from another table, put the lookup in a script include and call it from the ACL. That doesn’t make it cheaper by itself, but it lets you unit test the logic with ATF and reuse the same function in a before-query business rule.

Rows that vanish from lists

A read ACL that denies some rows doesn’t filter the query. The database returns a full page and the platform removes the rows the user can’t see afterwards. In the classic UI that shows up as a message such as “Number of rows removed from this list by Security constraints: 18”, and a page that should hold 20 records shows two. Users read that as a bug, and it also tells them how many records exist that they can’t see.

Two tools fix it. A before-query business rule adds the same restriction to the query itself (for example, opened_for = me OR assignment_group IN my groups), so the database only returns allowed rows and pagination stays honest. The ACL remains as the enforcement layer for every other path. On recent releases, Query ACLs (query_match and query_range operations) control which fields a user can filter and sort on, which stops people inferring hidden values through clever list filters. A good interview answer mentions both: the ACL is the rule, the query restriction is the user experience.

Mistakes that come up in reviews

Testing as admin. Admin users pass ACLs that have “Admin overrides” checked, which is the default. Your test passes whether or not the script works. Impersonate a real user with the real roles.

Duplicating logic in the condition and the script. If the script duplicates a condition, the two drift apart on the next change. Keep each rule in one place.

Using current on create. For a create ACL, current is the new, unsaved record, so fields the user hasn’t filled in yet are empty. A create rule that depends on assignment_group usually needs to be a write rule or a data policy instead.

Forgetting non-UI paths. The same read ACL protects REST Table API calls made by that user. Server-side GlideRecord code doesn’t check ACLs at all unless you use GlideRecordSecure or call canRead(). A scripted REST resource that queries with plain GlideRecord bypasses the ACL you just wrote.

Proving the rule works

Treat an ACL change like any other code change with a test plan. Pick three users: the case subject, a member of the assignment group, and an agent in a different group. Impersonate each one, turn on security debugging from the session debug modules, and open both the list and a single record. The debug output shows each ACL evaluated and whether it passed, so you can see your script running and what it returned. On releases that include Access Analyzer, you can also ask why a given user can or can’t reach a given record without impersonating them. Then repeat the read through the REST API with the same users, because that’s the path portal widgets and integrations take.

Write the outcome down as ATF tests where you can. ATF has steps for impersonating a user and checking that a record or field is accessible, so the rule stays tested after the next upgrade.

Interview exercise: the slow HR list

An HR agent says the case list takes eight seconds to load, but only since a new read ACL went live last week. The ACL script checks whether the user belongs to the case’s assignment group by querying sys_user_grmember. What do you do?

Answer

Start by confirming the cause rather than assuming it: open the list with security debugging on, or check the slow transaction log, and see how many times the ACL script runs and how long it takes. With a page of 50 rows, a per-row membership query adds up to 50 extra round trips to every load. The fix has two parts. Rewrite the script so it doesn’t query per row: compare opened_for first, guard empty groups, and use the session user’s group membership instead of a hand-written query. Then add a before-query business rule with the same restriction so the database returns only rows the agent may see; that also removes the “rows removed by security constraints” message and fixes paging. Finally, verify as a real HR agent, not as admin, and add an ATF test so a future change can’t quietly reintroduce the query.

More in ServiceNow

read ✓ServiceNow · mid

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