← All security articles
CRITICALWashington+

Secrets and Credentials Stored in ServiceNow Records

Domain 3: Data Protection & Classification·

Almost certainly yes. ServiceNow instances accumulate passwords, API keys and tokens across hundreds of fields, because the platform gets used as a credential store despite never having been designed as one. The locations are predictable — work notes and descriptions on change and incident records, script fields, and integration configuration — and the read-only detection script below sweeps them so you can measure the problem before deciding what to rotate.

What This Is

ServiceNow instances accumulate credentials, API keys, tokens, and other secrets across hundreds of tables and fields over time. Unlike purpose-built secrets management tools (HashiCorp Vault, AWS Secrets Manager), ServiceNow was not designed as a credential vault — yet it becomes one through daily operational use.

Secrets end up in ServiceNow through multiple pathways:

How Credentials Enter ServiceNow

Pathway Tables Affected How It Happens
Change management change_request, task Engineers paste connection strings, passwords, API keys in change descriptions and work notes during implementations
Incident management incident, problem Support staff paste error logs containing credentials; users submit incidents with passwords in descriptions
Knowledge Base kb_knowledge How-to articles include login procedures with actual credentials; integration guides with API keys
Integration configuration sys_properties, ecc_queue API keys stored in system properties; integration payloads in ECC queue containing auth tokens
Discovery discovery_credentials, sa_credential Purpose-built credential storage — but access controls may be insufficient
Script includes / Business rules sys_script, sys_script_include Hardcoded passwords in server-side scripts; plaintext connection strings
Email sys_email Auto-generated password reset emails stored in email logs; integration failure emails with credential details
Attachments sys_attachment Config files, .env files, certificate files attached to records
Catalog variables sc_item_option_mtom Service catalog forms that collect credentials from users
Update sets sys_update_xml Credentials captured in update set XML when properties or scripts are modified

Scale of the Problem

Security research has documented that ServiceNow instances routinely contain credentials and tokens scattered across multiple tables. When combined with access control weaknesses (Empty Condition ACLs on Sensitive Tables, Knowledge Base Article Access Control Exposure), these stored secrets become extractable by any user with broad read access — or by unauthenticated attackers if public access is misconfigured.

The risk multiplies in clone scenarios (Sub-Production Instance Cloning & Data Exposure Risks): when production is cloned to sub-production, all stored credentials are copied to environments with weaker access controls, broader user access, and less monitoring.

Why This Is Dangerous

Attack Scenario: Credential Harvesting from Change Records

Precondition: Attacker has itil role (standard help desk access) or exploits empty condition ACL on change_request (see Empty Condition ACLs on Sensitive Tables).

Attack chain:

  1. Query change records for credential patterns:

    GET /api/now/table/change_request
      ?sysparm_query=descriptionLIKEpassword
        ^ORdescriptionLIKEcredential
        ^ORdescriptionLIKEapi_key
        ^ORdescriptionLIKEtoken
        ^ORdescriptionLIKEconnection string
      &sysparm_fields=number,short_description,description,work_notes
      &sysparm_limit=500
    
  2. Extract work notes (journal fields) that contain credentials:

    GET /api/now/table/sys_journal_field
      ?sysparm_query=elementINwork_notes,comments
        ^valueLIKEpassword
        ^element_idISNOTEMPTY
      &sysparm_fields=value,element_id,name,sys_created_on
      &sysparm_limit=1000
    
  3. Search for specific credential formats:

    • AWS keys: AKIA[0-9A-Z]{16}
    • Azure connection strings: AccountKey=...
    • JDBC URLs: jdbc:...password=...
    • Bearer tokens: Bearer eyJ...
    • SSH keys: -----BEGIN RSA PRIVATE KEY-----
    • ServiceNow API tokens: token=...
  4. Harvest and validate: Test extracted credentials against target systems. Credentials in change records are often current — they were pasted during recent implementations.

Impact: A single change record from a database migration may contain the full JDBC connection string with production database credentials. A cloud infrastructure change may contain AWS access keys. A ServiceNow integration change may contain admin-level API tokens.

Attack Scenario: Script-Embedded Credentials

Precondition: Attacker has admin role or read access to sys_script, sys_script_include tables.

Attack chain:

  1. Search all server-side scripts for hardcoded credentials:

    // Background script to find credential patterns in scripts
    var tables = ['sys_script', 'sys_script_include', 'sys_script_client',
                  'sysauto_script', 'sys_processor', 'sys_ws_operation'];
    
    for (var t = 0; t < tables.length; t++) {
        var gr = new GlideRecord(tables[t]);
        gr.addQuery('active', true);
        gr.addEncodedQuery(
            'scriptLIKEpassword^ORscriptLIKEapikey^ORscriptLIKEapi_key' +
            '^ORscriptLIKEsecret^ORscriptLIKEtoken^ORscriptLIKEBearer'
        );
        gr.query();
        // Attacker reviews each matching script for actual credentials
    }
    
  2. Extract credentials from integration scripts: Common patterns:

    // BAD: Hardcoded credentials in script include
    var restMessage = new sn_ws.RESTMessageV2();
    restMessage.setEndpoint('https://api.vendor.com/v2/data');
    restMessage.setRequestHeader('Authorization', 'Bearer sk-live-abc123def456...');
    
    // BAD: Database password in scheduled job
    var jdbcUrl = 'jdbc:oracle:thin:admin/P@ssw0rd123@prod-db:1521/ORCL';
    
    // BAD: API key in system property accessed by script
    var apiKey = gs.getProperty('x_vendor.api_key'); // Property may contain plaintext key
    
  3. Pivot to external systems: Use extracted API keys and passwords to access vendor APIs, cloud platforms, databases, and other integrated systems.

Attack Scenario: Update Set Credential Leakage

Precondition: Access to sys_update_xml table (typically requires admin role, but may be broader in dev/test instances).

Attack chain:

  1. Query update sets for credential-containing records:

    GET /api/now/table/sys_update_xml
      ?sysparm_query=payloadLIKEpassword^ORpayloadLIKEapi_key^ORpayloadLIKEsecret
      &sysparm_fields=name,type,payload
      &sysparm_limit=100
    
  2. Extract credentials from XML payloads: When a system property or script containing credentials is modified, the full XML representation (including the credential value) is captured in the update set. Even if the property is later changed, the historical value persists in the update set record.

  3. Cross-reference across instances: Update sets exported from production to sub-production carry credentials. If sub-production has weaker controls, credentials are more accessible.

How to Detect

Comprehensive Credential Scanner

/*
 * DATA-006 Credential Scanner
 * Scans key tables and journal fields for stored secrets and credential patterns
 *
 * Run as: Background script with admin role
 * Impact: Read-only, may take several minutes on large instances
 * Versions: Orlando+
 *
 * WARNING: This script searches for credential patterns.
 * Output may contain actual credentials — handle securely.
 *
 * NOTE: Results capped at 500 per table/source.
 * Rerun with higher limit for full coverage on large instances.
 *
 * IMPORTANT: work_notes and close_notes are journal fields stored in
 * sys_journal_field, not on parent tables. A LIKE query against them
 * on change_request/incident will NOT return results. This script
 * scans sys_journal_field separately (Part 2).
 */

var SCAN_CONFIG = {
    // Tables and fields to scan
    // NOTE: Journal fields (work_notes, close_notes, comments) are NOT on
    // parent tables — they are stored in sys_journal_field and scanned in Part 2
    targets: [
        { table: 'change_request', fields: ['description'], label: 'Change Records' },
        { table: 'incident', fields: ['description'], label: 'Incidents' },
        { table: 'problem', fields: ['description'], label: 'Problems' },
        { table: 'kb_knowledge', fields: ['text', 'short_description'], label: 'KB Articles' },
        // Exclude type=password2 — these are already encrypted at rest
        { table: 'sys_properties', fields: ['value'], label: 'System Properties (plaintext only)', excludeType: 'password2' },
        { table: 'sys_script', fields: ['script'], label: 'Business Rules' },
        { table: 'sys_script_include', fields: ['script'], label: 'Script Includes' },
        { table: 'sysauto_script', fields: ['script'], label: 'Scheduled Jobs' },
        { table: 'sys_ws_operation', fields: ['operation_script'], label: 'Web Service Operations' },
        { table: 'sys_rest_message_fn', fields: ['rest_headers', 'rest_endpoint'], label: 'REST Message Functions' },
        { table: 'oauth_entity', fields: ['client_secret'], label: 'OAuth Applications' },
        // NOTE: sys_email body may truncate at 255 chars via getValue() in some versions.
        // Use gr.getElement('body').getValue() if results seem incomplete.
        { table: 'sys_email', fields: ['body', 'subject'], label: 'Email Records' },
    ],

    // Credential patterns — used for detailed matching on retrieved records
    patterns: [
        { name: 'Password field', regex: 'password\\s*[=:]\\s*["\']?[^\\s"\']{4,}' },
        { name: 'API Key', regex: 'api[_-]?key\\s*[=:]\\s*["\']?[A-Za-z0-9_\\-]{16,}' },
        { name: 'Bearer Token', regex: 'Bearer\\s+[A-Za-z0-9_\\-\\.]{20,}' },
        { name: 'AWS Access Key', regex: 'AKIA[0-9A-Z]{16}' },
        { name: 'AWS Secret Key', regex: 'aws_secret_access_key\\s*[=:]' },
        { name: 'Azure Connection', regex: 'AccountKey=[A-Za-z0-9+/=]{20,}' },
        { name: 'JDBC Connection', regex: 'jdbc:[a-z]+:.*password=' },
        { name: 'SSH Private Key', regex: 'BEGIN\\s+(RSA|DSA|EC|OPENSSH)\\s+PRIVATE\\s+KEY' },
        { name: 'Connection String', regex: '(Server|Data Source)=.*(Password|Pwd)=' },
        { name: 'Basic Auth Header', regex: 'Basic\\s+[A-Za-z0-9+/=]{10,}' },
        { name: 'OAuth Client Secret', regex: 'client_secret\\s*[=:]\\s*["\']?[A-Za-z0-9_\\-]{16,}' },
        { name: 'Private Key PEM', regex: 'BEGIN\\s+CERTIFICATE' },
        { name: 'Slack Token', regex: 'xox[bpors]-[0-9a-zA-Z\\-]{10,}' },
        { name: 'Generic Token', regex: 'token\\s*[=:]\\s*["\']?[A-Za-z0-9_\\-\\.]{20,}' },
    ],

    // LIKE terms for initial GlideRecord queries (fast pre-filter)
    searchTerms: ['password', 'api_key', 'apikey', 'secret', 'Bearer',
        'AKIA', 'AccountKey', 'jdbc:', 'BEGIN RSA', 'client_secret', 'private_key']
};

gs.info('=== DATA-006: CREDENTIAL SCAN RESULTS ===');
gs.info('Scan started: ' + new GlideDateTime().getDisplayValue());
gs.info('');

var totalFindings = 0;
var findingsByTable = {};

// --- Part 1: Scan regular table fields ---

for (var t = 0; t < SCAN_CONFIG.targets.length; t++) {
    var target = SCAN_CONFIG.targets[t];
    var tableFindings = 0;

    // Build OR query across all fields and search terms
    var queryParts = [];
    for (var f = 0; f < target.fields.length; f++) {
        for (var s = 0; s < SCAN_CONFIG.searchTerms.length; s++) {
            queryParts.push(target.fields[f] + 'LIKE' + SCAN_CONFIG.searchTerms[s]);
        }
    }

    var gr = new GlideRecord(target.table);
    if (!gr.isValid()) continue;

    gr.addEncodedQuery(queryParts.join('^OR'));

    // Exclude encrypted properties — they are not plaintext risks
    if (target.excludeType) {
        gr.addQuery('type', '!=', target.excludeType);
    }

    gr.setLimit(500);
    gr.query();

    while (gr.next()) {
        tableFindings++;
        totalFindings++;

        // Run regex patterns against field values to identify credential type
        var matchedPattern = 'Keyword match';
        var matchedField = 'unknown';

        for (var mf = 0; mf < target.fields.length; mf++) {
            var val = gr.getValue(target.fields[mf]) || '';
            for (var p = 0; p < SCAN_CONFIG.patterns.length; p++) {
                var re = new RegExp(SCAN_CONFIG.patterns[p].regex, 'i');
                if (re.test(val)) {
                    matchedPattern = SCAN_CONFIG.patterns[p].name;
                    matchedField = target.fields[mf];
                    break;
                }
            }
            if (matchedField !== 'unknown') break;
        }

        gs.info('[' + target.label + '] ' +
            (gr.getValue('number') || gr.getValue('name') || gr.getUniqueValue()) +
            ' | Field: ' + matchedField +
            ' | Pattern: ' + matchedPattern +
            ' | Created: ' + gr.getValue('sys_created_on'));
    }

    if (tableFindings > 0) {
        findingsByTable[target.label] = tableFindings;
    }
}

// --- Part 2: Scan journal fields (work_notes, close_notes, comments) ---
// Journal entries are stored in sys_journal_field, NOT on the parent table.
// Querying change_request.work_notes with LIKE will NOT return results.
// This is where developers most commonly paste credentials during implementations.

gs.info('');
gs.info('=== JOURNAL FIELD SCAN ===');

var journalTables = 'change_request,incident,problem';
var journalElements = 'work_notes,close_notes,comments';
var journalFindings = 0;

for (var st = 0; st < SCAN_CONFIG.searchTerms.length; st++) {
    var journal = new GlideRecord('sys_journal_field');
    journal.addQuery('name', 'IN', journalTables);
    journal.addQuery('element', 'IN', journalElements);
    journal.addQuery('value', 'LIKE', SCAN_CONFIG.searchTerms[st]);
    journal.setLimit(500);
    journal.query();

    while (journal.next()) {
        journalFindings++;
        totalFindings++;

        // Run regex patterns to identify credential type
        var jVal = journal.getValue('value') || '';
        var jPattern = 'Keyword match';
        for (var jp = 0; jp < SCAN_CONFIG.patterns.length; jp++) {
            var jre = new RegExp(SCAN_CONFIG.patterns[jp].regex, 'i');
            if (jre.test(jVal)) {
                jPattern = SCAN_CONFIG.patterns[jp].name;
                break;
            }
        }

        gs.info('[Journal] ' + journal.getValue('name') + '.' +
            journal.getValue('element') +
            ' | Record: ' + journal.getValue('element_id') +
            ' | Pattern: ' + jPattern +
            ' | Created: ' + journal.getValue('sys_created_on'));
    }
}

if (journalFindings > 0) {
    findingsByTable['Journal Fields (work_notes/comments)'] = journalFindings;
}

// --- Summary ---

gs.info('');
gs.info('=== SUMMARY ===');
gs.info('Total potential credential findings: ' + totalFindings);
for (var tbl in findingsByTable) {
    gs.info('  ' + tbl + ': ' + findingsByTable[tbl]);
}

gs.info('');
gs.info('NOTE: Results capped at 500 per table/source.');
gs.info('Rerun with higher limit for full coverage on large instances.');
gs.info('');
gs.info('CRITICAL: Review each finding manually to confirm actual credentials.');
gs.info('NEXT STEPS:');
gs.info('1. Rotate any confirmed credentials immediately');
gs.info('2. Remove credentials from records (replace with vault references)');
gs.info('3. Implement credential detection business rule (see remediation)');
gs.info('4. Check access controls on tables with credentials (see ACL-001)');
gs.info('5. If KB articles contain credentials, check KB access (see ACL-009)');

Remediation

Step 1: Immediate — Rotate Discovered Credentials

For every credential found by the scanner:

  1. Identify the target system the credential accesses
  2. Rotate the credential in the target system
  3. Update any legitimate references to the new credential (use credential vault, not plaintext)
  4. Redact or delete the credential from the ServiceNow record
  5. Check audit logs for unauthorized use of the credential

Step 2: Implement Credential Detection Business Rule

/*
 * Business Rule: Detect and alert on credential storage
 *
 * Table: change_request (also create for incident, problem, kb_knowledge)
 * When: Before insert, Before update
 * Active: true
 *
 * This rule scans work notes and descriptions for credential patterns
 * and creates a security alert if found.
 */

(function executeRule(current, previous) {
    var fieldsToCheck = ['description', 'work_notes', 'close_notes'];
    var patterns = [
        /password\s*[=:]\s*["']?[^\s"']{4,}/i,
        /api[_-]?key\s*[=:]\s*["']?[A-Za-z0-9_\-]{16,}/i,
        /Bearer\s+[A-Za-z0-9_\-\.]{20,}/,
        /AKIA[0-9A-Z]{16}/,
        /AccountKey=[A-Za-z0-9+\/=]{20,}/,
        /jdbc:[a-z]+:.*password=/i,
        /BEGIN\s+(RSA|DSA|EC|OPENSSH)\s+PRIVATE\s+KEY/,
        /client_secret\s*[=:]\s*["']?[A-Za-z0-9_\-]{16,}/i
    ];

    for (var f = 0; f < fieldsToCheck.length; f++) {
        var fieldName = fieldsToCheck[f];
        var value = current.getValue(fieldName) || '';

        for (var p = 0; p < patterns.length; p++) {
            if (patterns[p].test(value)) {
                // Create security alert
                var alert = new GlideRecord('incident');
                alert.initialize();
                alert.setValue('short_description',
                    '[SECURITY] Potential credential detected in ' +
                    current.getTableName() + ': ' + current.getValue('number'));
                alert.setValue('description',
                    'A credential pattern was detected in the "' + fieldName +
                    '" field of ' + current.getTableName() + ' ' +
                    current.getValue('number') + '.\n\n' +
                    'Please review and remove the credential.\n' +
                    'Use ServiceNow Credential Vault or an external secrets manager instead.\n\n' +
                    'Reference: DATA-006 playbook');
                alert.setValue('category', 'security');
                alert.setValue('priority', 2);
                alert.setValue('assignment_group', 'Security Operations');
                alert.insert();

                // Log the detection (without the actual credential)
                gs.info('[DATA-006] Credential pattern detected in ' +
                    current.getTableName() + ' ' + current.getValue('number') +
                    ' field: ' + fieldName);

                break; // One alert per record is sufficient
            }
        }
    }
})(current, previous);

Step 3: Migrate to Proper Credential Storage

ServiceNow provides three purpose-built credential storage mechanisms:

1. Credential Store (discovery_credentials table) For Discovery, Orchestration, and Service Mapping credentials. Encrypted at rest with instance-level keys. Configure via: Connections & Credentials > Credentials. Ensure ACLs restrict access to security_admin — the default ACL may be too permissive.

2. Connection & Credential Aliases For REST/SOAP integrations (Flow Designer, IntegrationHub). Store credentials as named aliases that scripts reference indirectly — the credential value never appears in code. Configure via: Connections & Credentials > Connection & Credential Aliases.

3. External Credential Resolver (ECR) For MID Server integrations that must retrieve secrets from an external vault (HashiCorp Vault, CyberArk, AWS Secrets Manager). The MID Server calls the external vault at runtime — credentials never enter ServiceNow. Configure via: MID Server > Properties > External Credential Storage.

// GOOD: Use Connection Alias instead of hardcoded credential
var restMessage = new sn_ws.RESTMessageV2('Vendor API', 'POST');
// Credential is resolved from the alias — not in code
restMessage.execute();

// GOOD: Use gs.getProperty() with encrypted property (type: password2)
var apiKey = gs.getProperty('x_vendor.secure_api_key');
// Property is encrypted at rest, not readable via Table API

For system properties containing credentials:

  1. Change property type from string to password2 (encrypts at rest)
  2. Restrict READ ACL on sys_properties to security_admin role
  3. Never reference encrypted properties in client-callable Script Includes

Step 4: Implement Data Loss Prevention for Scripts

/*
 * Script scan — detect hardcoded credentials in new/modified scripts
 *
 * Business Rule on: sys_script, sys_script_include, sysauto_script
 * When: Before insert, Before update
 */

(function executeRule(current, previous) {
    var script = current.getValue('script') || '';

    var dangerPatterns = [
        { pattern: /['"][A-Za-z0-9_\-]{20,}['"]/g, name: 'Long string literal (possible key)' },
        { pattern: /password\s*=\s*['"][^'"]+['"]/gi, name: 'Hardcoded password' },
        { pattern: /Bearer\s+[A-Za-z0-9_\-\.]{20,}/g, name: 'Bearer token' },
        { pattern: /AKIA[0-9A-Z]{16}/g, name: 'AWS access key' },
    ];

    for (var p = 0; p < dangerPatterns.length; p++) {
        if (dangerPatterns[p].pattern.test(script)) {
            gs.addErrorMessage(
                'SECURITY WARNING: This script appears to contain a hardcoded credential (' +
                dangerPatterns[p].name + '). Use gs.getProperty() with a secure system property ' +
                'or the Credential Vault instead. Reference: DATA-006.');

            // Don't block — warn. Log for security team review.
            gs.info('[DATA-006] Credential pattern "' + dangerPatterns[p].name +
                '" detected in ' + current.getTableName() + ': ' + current.getValue('name'));
        }
    }
})(current, previous);

Regulatory Impact

NIS2 Mapping

Article Requirement How Stored Credentials Violate It Evidence After Fix
Art.21§2(a) Risk analysis and IS security policies Unmanaged credentials create uncontrolled access paths to critical systems Credential inventory completed, vault migration documented, detection rules active
Art.21§2(h) Cryptography and encryption policies Credentials stored in plaintext in database records violate encryption policy requirements All credentials migrated to encrypted vault; plaintext credential detection active

DORA Mapping

Article Requirement How Stored Credentials Violate It Evidence After Fix
Art.9§4(c) Mechanisms to detect anomalous activities Scattered credentials prevent proper access monitoring — can't audit what you don't know exists Credential inventory enables access monitoring; detection rules prevent new storage
Art.9§4(d) ICT operations security Credentials in operational records create unmonitored access paths All integration credentials managed via vault with rotation and audit logging

ISO 27001:2022 Mapping

Control Requirement How Stored Credentials Violate It Evidence After Fix
A.8.24 Use of cryptography Plaintext credentials in database records Encrypted credential vault in use; no plaintext credentials in operational records
A.5.17 Authentication information Authentication secrets not protected during storage Centralized credential management with access controls and rotation

Expert Notes

Practitioner annotations pending — article content has been technically validated.