← Back to Blog

10 ServiceNow security gaps that pass every audit

Revised September 2026. Every property, table and field reference in this post has been re-verified against Zurich Patch 6 and Australia Patch 3 schema captures, and every script below was re-run on a live Australia Patch 3 instance — the numbers quoted are that run's actual output. Four references in the April 2026 version were wrong. They are corrected below, and where a name could not be verified it was removed rather than replaced with a plausible-looking substitute.

Most ServiceNow security reviews check the obvious: password policies, role assignments, encryption at rest. After ten years in ServiceNow's Office of the CISO and 100+ instance assessments since, the risk I keep finding lives in configurations that neither the auditor's checklist nor the platform's own hardening score reaches.

Ten of them, with the property name, a script you can run yourself, and the regulatory article each one lands under.

1. Tables with no ACL of their own

When the only rules that match a request are the wildcard table ACLs, what happens next is decided by glide.sm.default_mode. Set to deny, the wildcard rules restrict read, write, create and delete on every table unless the caller has admin or satisfies some other table ACL. Set to allow, anything without explicit ACLs is reachable.

Two things about this property are worth knowing before you touch it.

It is a one-way door. Once glide.sm.default_mode has been set to Deny Access it cannot be reset to Allow Access. ServiceNow classifies it as a safe-harbor property: non-revertible, by design. Set it deliberately, in a sub-production instance first, with the blast radius understood.

It governs less than its name suggests. By default the wildcard table ACLs are the only rules that consult it, and operations outside the CRUD four — report_on, personalize_choices — are unaffected entirely. Reading the value tells you what happens to a table with no rules of its own. It tells you nothing about a table that has rules that are wrong.

The exposure is rarely a table you know about. It is the custom table a developer created eleven months ago on a scoped app, reachable through a portal widget nobody threat-modelled.

Detection — script S1. Read-only. Builds the read-ACL coverage set once, then walks each table's inheritance chain, because an extended table with no ACL of its own may still be protected by its parent's.

var covered = {};
var acl = new GlideRecord('sys_security_acl');
acl.addQuery('active', true);
acl.addQuery('operation.name', 'read');   // operation is a reference field, not a string
acl.query();
while (acl.next()) {
    covered[(acl.getValue('name') || '').split('.')[0]] = true;
}

var parent = {};
var meta = new GlideRecord('sys_db_object');
meta.query();
while (meta.next()) {
    parent[meta.getValue('name')] = meta.super_class.name.toString();
}

function protectedByChain(table) {
    var seen = {};
    while (table && !seen[table]) {
        if (covered[table]) return true;
        seen[table] = true;
        table = parent[table];
    }
    return false;
}

var gaps = [];
for (var t in parent) {
    if (t.indexOf('sys_') === 0 || t.indexOf('ts_') === 0) continue;
    if (protectedByChain(t)) continue;
    if (/\d{4}$/.test(t)) continue;      // rotated log shards, not findings
    gaps.push(t);
}
gaps.sort();
gs.info('Tables with no read ACL on the inheritance chain: ' + gaps.length);
gs.info(gaps.slice(0, 25).join(', '));

On a stock Australia Patch 3 developer instance this block — exactly as printed — returns 166 tables in about four seconds, having read 8,660 active read ACLs across 6,456 tables. Three things about that number are worth understanding before you read your own.

It excludes rotated log shardssyslog_transaction0004, discovery_log0002 and the like. Without the four-digit filter the same instance reports 453, and the extra 287 are partitions of tables you already know about. An inflated number is worse than no number: it buries the 166 that matter.

It treats a table covered only by a wildcard ACL (*, *.*) as a gap, not as coverage. A wildcard is precisely the case glide.sm.default_mode exists to arbitrate, so counting it as protection would hide the finding rather than report it.

And it looks at read alone. Run the same logic for write and delete before drawing conclusions.

Fix: decide the default-deny posture deliberately rather than inheriting it, knowing it cannot be undone. Then create explicit baselines for every table the script returns.

Maps to: NIS2 Art. 21(2)(i) access control policies · DORA Art. 9(4)(c) logical access to ICT assets · ISO 27001:2022 A.8.3 information access restriction

→ Deeper: Empty Condition ACLs on Sensitive Tables

→ Scan check: none yet. Where a gap below has one, it is named under it. This one has none — and it is the gap I rate highest, which makes it the honest place to say so. The script above is, for now, the whole of the tooling for it. Until a check lands, run that script yourself rather than reading a clean scan as coverage of this.

2. REST surfaces reachable without the authentication you assume

Three different surfaces, three different exposures, and the one people check is the least interesting.

The endpoint families have their own authentication switches. Sixteen glide.basicauth.required.* properties decide whether each family accepts an unauthenticated request: .api, .soap, .wsdl, .xsd, .schema, .xml, .jsonv2, .csv, .excel, .pdf, .rss, .unl, .importprocessor, .scriptedprocessor, .xmloutputprocessor, .databrokerrestapiprocessor. All sixteen ship true. Any one of them set to false opens an entire family — and because they are sixteen separate properties, a hardening review that checks "REST authentication" as a single item checks about a sixteenth of the surface.

Scripted REST resources can be published anonymously, one at a time. They live in the Scripted REST Resource table, sys_ws_operation, under a definition in sys_ws_definition, and each carries a boolean field, requires_authentication. Set it false and that one resource answers anyone. ServiceNow's own Instance Security Center ships a check for exactly this — Review public REST API endpoints.

Here is the part nobody tells you. On a stock Australia Patch 3 instance, with no customisation at all, 40 scripted REST resources already ship with requires_authentication = false — across 243 REST definitions. They are ServiceNow's own: service catalog price and variable lookups, knowledge search facets, Virtual Agent settings, key-management endpoints. Most have their own downstream checks and are anonymous by design.

That baseline is the finding. If you query for anonymous endpoints and get 40, you have learned nothing; if you get 43, the three you need to look at are invisible in the count. Snapshot the list at go-live and diff it — the delta is the signal, not the total. And the endpoint you are looking for will be one of yours, described as temporary, two years ago.

The Table API is protected by a role, and the role is broader than you think. Access needs snc_platform_rest_api_access — which on the instance I checked covers the Table API, Import Set API, Aggregate API and Attachment API — and that role is contained in both itil and mid_server. Every ITIL user and every MID Server account already holds it. So the question is not who was granted the role; it is who inherits it, which means walking role containment rather than listing direct grants.

Fix: treat the sixteen properties as one baseline item with sixteen values, not one. Diff the anonymous-resource list against a go-live snapshot instead of counting it. And audit the effective holders of the REST access role — inherited included — the way you audit admin.

Maps to: NIS2 Art. 21(2)(e) security in acquisition, development and maintenance · DORA Art. 9(2) ICT security policies, procedures and tools · ISO 27001:2022 A.8.5 secure authentication

→ Scan check: check-rest-anonymous-access.js

3. Hardening properties that drift after every clone and upgrade

Not debug settings — hardening settings that were correct at go-live and are not correct now. Clone and upgrade both move them.

Detection — script S3. Reports the value, or an explicit not-set, for each.

var baseline = [
    'glide.sm.default_mode',
    'glide.script.secure.ajaxgliderecord',
    'glide.security.use_csrf_token',
    'glide.sys.log_impersonation',
    'glide.sys.log_impersonation.non_interactive',
    'glide.ui.session_timeout'
];
baseline.forEach(function (p) {
    var v = gs.getProperty(p, null);
    gs.info(p + ' = ' + (v === null ? '[NOT SET]' : v));
});

[NOT SET] is not the same as false, and the distinction is subtler than it looks. On the instance I ran this against, the output was:

glide.sm.default_mode = deny
glide.script.secure.ajaxgliderecord = true
glide.security.use_csrf_token = true
glide.sys.log_impersonation = true
glide.sys.log_impersonation.non_interactive = [NOT SET]
glide.ui.session_timeout = 30

glide.sys.log_impersonation reads true — and there is no record for it in sys_properties at all. The platform carries a built-in default, and gs.getProperty serves it. So "the property is missing" and "the behaviour is off" are different statements, and reading the properties list in the UI will not tell you which one you are looking at. The only reliable test is the one above: ask the platform, with an explicit fallback, and see what comes back.

glide.sys.log_impersonation.non_interactive is the opposite case: no record, no built-in default, genuinely off. That is a real gap, and gap 7 is about it.

glide.ui.session_timeout is in minutes, and anything above 1440 is treated as one day. There is no single correct default to compare against: of the two instances I checked, one shipped 90 and the other 30.

Two of these are safe-harbor propertiesglide.sm.default_mode and glide.script.secure.ajaxgliderecord. Once changed, they cannot be changed back. Read that sentence again before you run a bulk hardening script.

Fix: version the baseline, diff it after every clone and every upgrade, and treat a diff as a change event rather than a config note.

Maps to: NIS2 Art. 21(2)(e) security in acquisition, development and maintenance · DORA Art. 9(4)(b) network and infrastructure management · ISO 27001:2022 A.5.37 documented operating procedures

→ Scan checks: check-csrf-token-enforcement.js · check-session-timeout.js · check-platform-build-drift.js

4. Elevated service accounts nobody owns

The most common finding across every assessment I run, and the pattern never varies. Someone needed an account for a MID Server, a SIEM forwarder, a third-party integration. admin was easier. That was eighteen months ago and the credential is static.

Detection — script S2. Deduplicates by user, and separates granted roles from inherited ones, because sys_user_has_role carries both and counting rows overstates the population.

var elevated = {};
var hr = new GlideRecord('sys_user_has_role');
hr.addEncodedQuery('role.nameINadmin,security_admin^user.active=true');
hr.query();
while (hr.next()) {
    var uid = hr.getValue('user');
    var tag = hr.getDisplayValue('role') + (hr.getValue('inherited') == '1' ? ' (inherited)' : '');
    elevated[uid] = elevated[uid] || [];
    if (elevated[uid].indexOf(tag) === -1) elevated[uid].push(tag);
}

var ids = Object.keys(elevated);
gs.info('Distinct active users with admin or security_admin: ' + ids.length);

if (ids.length) {
    var u = new GlideRecord('sys_user');
    u.addQuery('sys_id', 'IN', ids.join(','));
    u.query();
    while (u.next()) {
        var last = u.getValue('last_login_time');
        var svc  = u.getValue('web_service_access_only') == '1' ||
                   u.getValue('internal_integration_user') == '1';
        var dormant = !last || last < gs.daysAgo(90);
        if (svc || dormant) {
            gs.info([
                u.getValue('user_name'),
                elevated[u.getValue('sys_id')].join(' + '),
                svc ? 'service account' : 'interactive',
                'last login: ' + (last || 'never')
            ].join(' | '));
        }
    }
}

Two populations come back and they need different treatment. Accounts flagged service account — either web-service-only or marked as an internal integration user — are integrations holding platform-wide privilege. Accounts flagged interactive with no login in 90 days are dormant admins: a different problem with the same blast radius.

On the instance I ran this against — a developer instance, so read it as a shape rather than as a scary number — 20 role grants resolved to 19 distinct active users, of whom 17 were flagged: sixteen dormant interactive admins, and one service account holding admin that had never logged in at all. That last row is the one that matters. It is exactly what a forgotten integration looks like from the outside: active, privileged, no login history, nobody's name on it.

Note last_login_time, not last_login. Both fields exist on sys_user; the first is a date-time and the second is a date, and mixing them up costs you a day of resolution on exactly the accounts you care about.

Fix: a dedicated role per integration, scoped to the tables that integration touches. No shared accounts. OAuth in place of static credentials wherever the far end supports it.

Maps to: NIS2 Art. 21(2)(i) access control policies and asset management · DORA Art. 9(4)(c) logical access to ICT assets · ISO 27001:2022 A.8.2 privileged access rights

→ Deeper: Service Account & Integration User Management

→ Scan checks: check-elevated-role-assignments.js · check-inactive-users-with-roles.js · check-basic-auth-role-without-wsao.js

5. Display business rules pushing more to the browser than intended

Correcting the April version of this section: display business rules run server-side, before the form loads. What reaches the client is whatever the rule places in g_scratchpad.

That is the exposure. The rule has full server-side GlideRecord access, and g_scratchpad goes to the browser in full, for whoever has the form open. I have seen scratchpad payloads carrying internal hostnames, values read straight out of system properties, and complete user records for users the session had no right to read.

Fix: audit every display business rule for what it writes to the scratchpad. Treat g_scratchpad as a public API — everything in it is readable by the user on that form, regardless of what ACLs say about the source records.

Maps to: NIS2 Art. 21(2)(e) security in acquisition, development and maintenance · DORA Art. 9(4)(a) information security policy · ISO 27001:2022 A.8.28 secure coding

→ Scan check: check-glide-record-vs-secure.js (adjacent — widget server scripts)

6. Knowledge bases and catalog items with no working user criteria

Runbooks, architecture notes, and occasionally credentials, sitting in knowledge articles. Catalog variables exposing infrastructure detail. Common because the people writing them are thinking about documentation, not access control.

User criteria is the control, and it works — when someone configured it and someone tested it. Most estates set it once at implementation and never look again.

Fix: quarterly review of every knowledge base and its user criteria, tested from the outside. Log in as a portal user with no roles and see what comes back. Reading the criteria records is not the test; the session is.

Maps to: NIS2 Art. 21(2)(i) access control policies · DORA Art. 9(4)(c) logical access to ICT assets · ISO 27001:2022 A.5.15 access control

7. Impersonation without the logging turned on

Correcting the April version: the property is glide.sys.log_impersonation, not what the earlier draft said. It governs interactive impersonation — through the UI — and here is the good news I did not expect to find: it reads true on a stock instance, with no property record behind it. Interactive impersonation logging is on by default. If your April reading of this post sent you looking for a property that was not there, that is why.

The one that matters is glide.sys.log_impersonation.non_interactive, which covers impersonation performed by applications, scripts and background jobs. That is the path an insider or a compromised integration would use. It has no record and no built-in default — it returns nothing at all — and ServiceNow documents it as off by default. You are not toggling it; you are creating it.

Even with it on, impersonations of the built-in system, soap.guest and guest users are not logged, because the platform impersonates them routinely to do ordinary work. glide.sys.log_impersonation.non_interactive.exclusion is where you add any others you want to suppress — it too is absent until you create it.

Note what logging does and does not give you. Actions taken during impersonation are recorded as the impersonated user. The impersonation log tells you the session existed; correlating it against sys_audit for the record in question is what tells you who was really behind a change.

Fix: confirm the interactive property really is true on your instance rather than assuming it, then create the non-interactive one and set it to true. Configure the out-of-box impersonation notification under Security Event Notifications, scoped at minimum to impersonation of any account holding an approval role. Correlate impersonation windows against audit history as a scheduled job, not on demand after an incident.

Maps to: NIS2 Art. 21(2)(b) incident handling · DORA Art. 10(1) prompt detection of anomalous activities · ISO 27001:2022 A.8.15 logging

8. Inbound email actions that execute scripts on unverified senders

Correcting the April version: inbound email actions live in sysevent_in_email_action. The table named in the earlier draft, sysevent_email_action, holds outbound email notifications — a different surface with a different problem, and one that every authenticated user can read.

An inbound email action can run server-side script based on the content of an incoming message. Without sender verification in the condition, that is remote script execution triggered by anyone who knows the instance's inbound address.

Outbound deserves a look too, though it is a lesser issue: notification templates that embed sys_id values or direct record URLs hand a recipient a partial map of your data model.

The list is short enough that there is no excuse for not reading it: a stock instance carries 14 inbound email actions. Whatever your instance has beyond that, someone wrote on purpose.

Fix: every inbound email action that executes script gets a sender condition. Review the full sysevent_in_email_action list, not just the ones your team wrote — inactive-but-present actions still fire if reactivated.

Maps to: NIS2 Art. 21(2)(e) security in acquisition, development and maintenance · DORA Art. 9(2) ICT security policies, procedures and tools · ISO 27001:2022 A.8.26 application security requirements

9. Update sets and clone hygiene

Two patterns, both routine.

Hardcoded secrets in script includes and business rules get captured into update sets and promoted across environments. Your production credential now exists in dev, in test, and in whatever XML someone exported.

Cloning production to sub-prod carries integration configuration along with it. Sub-prod typically has wider access, weaker controls and less monitoring — so the credential's effective exposure goes up the moment it lands there.

Fix: data preservers and exclusions configured before the clone, reviewed after every one. An update set review step that scans for credential-shaped strings before promotion — this is a diff check, and it automates cleanly.

Maps to: NIS2 Art. 21(2)(e) security in acquisition, development and maintenance · DORA Art. 9(4)(e) ICT change management · ISO 27001:2022 A.8.33 protection of test information

→ Deeper: Instance Cloning & Data Masking Failures · Secrets and Credentials Stored in ServiceNow Records

→ Scan checks: check-update-set-xml-suspicious.js · check-hardcoded-credentials.js

10. No detection on the changes that matter

The meta-gap. sys_audit is a record-history mechanism, not a security detection layer, and using it as one leaves you reading configuration changes after the fact with no severity attached to any of them.

What you want alerting on is a narrow list: ACL creation and modification, changes to the hardening properties in gap 3, role grants to admin and security_admin, script include edits that touch authentication or ACL evaluation, and user criteria changes on knowledge bases. Five event types. None of them is high-volume in a healthy instance, which is exactly why they make good alerts.

Fix: treat those five as security events with owners and response paths, separate from your change management queue.

Maps to: NIS2 Art. 21(2)(b) incident handling · DORA Art. 10(1) and 10(2) detection mechanisms and alert thresholds · ISO 27001:2022 A.8.15 logging

→ Deeper: ServiceNow Forensic Evidence Collection & Chain of Custody · NIS2 Article 23 — Incident Reporting Workflow Implementation

→ Scan checks: check-audit-coverage-gap.js · check-oob-acl-modifications.js

The common thread

Every one of these sits in production instances that passed their last audit, and that is the point. An auditor checks what the framework tells them to check. An attacker checks what is reachable.

Five of these ten have a check in the open-source pack at github.com/nowisor/instance-scan-pack (Apache-2.0), and a sixth has one that only touches the edge of it. Each is named under its gap above. Run the pack against your own instance without involving us: read-only, safe on production.

The other four — including gap 1, which I rate highest — are the scripts in this post and nothing more. That is why they are printed in full rather than summarised, and why the count is stated rather than rounded up. If you want the findings framed as attack chains and mapped to your regulatory obligations, that is what Nowisor does on top.


Rachid Harrando spent ten years as Principal Security Advisor in ServiceNow's Office of the CISO. He is co-founder of Black Hat Arsenal (2011) and a Review Board member, author of Securing ServiceNow: A CISO's Field Guide (Leanpub, 2026), and founder of Nowisor.

CHECK YOUR OWN INSTANCE

Check your exposure against the current ServiceNow CVEs

No signup, no instance access. You give a release and patch level; you get a verdict.

Run the CVE exposure check →

Or see what the full engine covers.

← All posts