Oh no - an unsupported Authentication Method!
Sometimes an OAuth integration starts with a simple client_id and client_secret. Other times, the provider expects cryptographic signing, JWT assertions, and elliptic curve encryption.
Apple Business Manager is one of those integrations.
The good news is that once the moving parts are broken down, the architecture becomes much easier to understand and reusable for other modern integrations.
This guide walks through a practical ServiceNow design for handling ES256-signed JWT authentication using a MID Server.
Along the way, this article also explains:
What a JWT actually is
What ES256 signing means
Why cryptographic signing matters
What JAR files are and why the MID Server needs them
How to safely obtain external Java libraries
How ServiceNow and MID Servers work together securely
First: What Is a JWT?
JWT stands for JSON Web Token. At its core, a JWT is simply a structured block of JSON data that can be securely transmitted between systems.
A JWT normally contains three parts:
Header — describes the signing algorithm
Payload — contains claims and identity information
Signature — proves the token was created by a trusted source
Important concept:
A JWT is not automatically encrypted. In many OAuth flows, the JWT is signed rather than encrypted.
Signing proves authenticity. It tells Apple:
“This request genuinely came from the owner of the private key.”
That is exactly what Apple Business Manager expects during OAuth authentication.
What Does ES256 Mean?
ES256 is the signing algorithm Apple requires for JWT assertions.
Under the hood:
ES = Elliptic Curve Digital Signature Algorithm (ECDSA)
256 = SHA-256 hashing
Compared to older RSA-based signing approaches, elliptic curve cryptography provides strong security with smaller key sizes and efficient performance.
In practical terms:
A private key signs the JWT
Apple validates the signature using the public key
The token proves the caller is trusted
ServiceNow itself is intentionally restrictive around direct cryptographic access. That is where the MID Server becomes useful.
The Architecture
Rather than pushing the entire integration onto the MID Server, the cleaner pattern is:
Let ServiceNow orchestrate the integration while the MID Server performs only the signing operation.
| Component | Responsibility |
|---|---|
| ServiceNow | Configuration, token caching, API orchestration, and Apple API calls |
| MID Server | Generate ES256-signed JWT assertions |
This separation keeps the design clean and secure.
The Authentication Flow
ServiceNow checks for a cached Apple access token
If the token exists and remains valid, ServiceNow reuses it
If the token expired, ServiceNow retrieves the Apple private key securely
ServiceNow sends signing inputs to the MID Server
The MID Server generates an ES256-signed JWT assertion
The assertion returns to ServiceNow
ServiceNow exchanges the assertion for an OAuth bearer token
ServiceNow calls Apple Business Manager APIs directly
Key design idea:
The MID Server is not the integration runtime. It acts as a focused cryptographic signing service.
Understanding the Private Key
Apple provides a private key when creating API credentials.
This private key is the most sensitive part of the integration. Anyone with access to it can generate valid JWT assertions.
The key normally arrives in PEM format:
-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----Or:
-----BEGIN EC PRIVATE KEY-----...-----END EC PRIVATE KEY-----These are simply different formatting standards for representing cryptographic key material.
The implementation should support both.
Where Should the Private Key Be Stored?
A common mistake is storing private keys inside plain text system properties.
Instead:
Store the private key inside the ServiceNow Credentials table
Reference the credential using a system property
Restrict credential access carefully
| Value | Storage Location |
|---|---|
| Apple private key | Credential password field |
| Credential sys_id | System property |
| Apple team ID | System property |
| Apple key ID | System property |
| MID Server name | System property |
What Are JAR Files?
If this is the first time working with MID Server Java dependencies, the term JAR file can look intimidating.
A JAR file (Java ARchive) is simply a packaged Java library.
Think of it as:
“A reusable bundle of Java code that applications can import and use, not unlike Script Includes.”
In this implementation, the MID Server uses external Java libraries to:
Create JWT objects
Generate signatures
Handle cryptographic operations
Without these libraries, the MID Server would not understand classes like:
Packages.com.nimbusds.jwt.SignedJWTRequired MID Server Libraries
This implementation uses the Nimbus JOSE JWT library ecosystem:
nimbus-jose-jwt
json-smart
accessors-smart
These libraries should be uploaded into the ServiceNow JAR Files table and synced to the MID Server.
How to Safely Obtain JAR Files
This part matters more than many developers realize.
JAR files are executable Java libraries. Downloading random JARs from untrusted sites introduces serious supply chain risk.
Safe sources include:
The vendor’s official GitHub repository
Maven Central
Official release pages
Avoid:
Unknown mirror sites
Forum attachments
Random “free JAR download” websites
Good practice:
Verify version numbers, checksums, and release signatures whenever possible before uploading libraries into production MID Servers.
Generating the JWT Assertion
Once the libraries are available, the MID Server can create and sign the JWT assertion.
The JWT includes claims such as:
| Claim | Purpose |
|---|---|
iss | Apple Team ID |
sub | Apple Client ID |
aud | Apple token endpoint |
iat | Issued timestamp |
exp | Expiration timestamp |
jti | Unique token identifier |
The assertion should remain short-lived. This implementation uses a 15 minute expiry window.
Example MID Server Signing Flow
var signedJWT = new Packages.com.nimbusds.jwt.SignedJWT(header, claimsSet);var signer = new Packages.com.nimbusds.jose.crypto.ECDSASigner(privateKey);signedJWT.sign(signer);return String(signedJWT.serialize());This is the moment the unsigned JWT becomes cryptographically trusted.
Token Caching Matters
Without caching, every API call would trigger:
A new JWT assertion
A new OAuth request
Another MID Server signing operation
That increases latency and unnecessary load.
Instead, ServiceNow stores the OAuth bearer token temporarily and refreshes it only when necessary.
var token = new ABMAuth().getAccessToken(false);Consumers of the integration never need to care whether the token came from cache or from a fresh authentication cycle.
Security Considerations
Never log private keys
Never log bearer tokens
Restrict credential access carefully
Use short-lived JWT assertions
Use trusted MID Servers only
Review third-party JAR sources carefully
The JWT assertion is temporary, but while valid it can still request OAuth tokens. Treat it as sensitive.
The Bigger Pattern
Apple Business Manager is not unique here.
Many modern integrations now rely on signed JWT assertions instead of static shared secrets.
Once this architecture exists, the same approach becomes reusable across:
Financial APIs
Healthcare integrations
Enterprise identity providers
Zero trust authentication systems
The pattern remains the same:
Store sensitive material securely
Use ServiceNow for orchestration
Delegate cryptographic signing to the MID Server
Cache tokens intelligently
Keep the consumer experience simple
Final Thoughts
ES256 JWT authentication looks intimidating the first time it appears in an integration project.
There are acronyms everywhere:
JWT
ECDSA
PEM
OAuth
JAR files
PKCS#8
But underneath the terminology, the architecture is surprisingly logical:
ServiceNow manages the workflow
The MID Server handles cryptographic signing
Apple validates the signature
OAuth tokens unlock the API
Eventually, all of that complexity becomes:
var token = new ABMAuth().getAccessToken(false);That single line hides a modern, secure, reusable authentication architecture.
var ABMMidAppleToken = Class.create();ABMMidAppleToken.prototype = { initialize: function() { this.clientId = probe.getParameter('client_id'); this.teamId = probe.getParameter('team_id'); this.keyId = probe.getParameter('key_id'); this.privateKeyPem = probe.getParameter('private_key_pem');
this.appleAudience = 'https://account.apple.com/auth/oauth2/v2/token'; },
getClientAssertion: function() { try { this._validateInputs();
var clientAssertion = this._createClientAssertion();
return JSON.stringify({ success: true, client_assertion: clientAssertion }); } catch (e) { return JSON.stringify({ success: false, error_message: String(e) }); } },
_validateInputs: function() { var missing = [];
if (!this.clientId) { missing.push('client_id'); }
if (!this.teamId) { missing.push('team_id'); }
if (!this.keyId) { missing.push('key_id'); }
if (!this.privateKeyPem) { missing.push('private_key_pem'); }
if (missing.length > 0) { throw 'Missing MID token inputs: ' + missing.join(', '); } },
_createClientAssertion: function() { var privateKey = this._loadPrivateKey(this.privateKeyPem);
var nowMillis = new Packages.java.util.Date().getTime(); var issuedAt = new Packages.java.util.Date(nowMillis); var expiresAt = new Packages.java.util.Date(nowMillis + (15 * 60 * 1000));
var header = new Packages.com.nimbusds.jose.JWSHeader .Builder(Packages.com.nimbusds.jose.JWSAlgorithm.ES256) .keyID(this.keyId) .build();
var claimsSet = new Packages.com.nimbusds.jwt.JWTClaimsSet.Builder() .issuer(this.teamId) .subject(this.clientId) .claim('aud', this.appleAudience) .issueTime(issuedAt) .expirationTime(expiresAt) .jwtID(String(Packages.java.util.UUID.randomUUID().toString())) .build();
var signedJWT = new Packages.com.nimbusds.jwt.SignedJWT(header, claimsSet); var signer = new Packages.com.nimbusds.jose.crypto.ECDSASigner(privateKey);
signedJWT.sign(signer);
return String(signedJWT.serialize()); },
_loadPrivateKey: function(privateKeyPem) { var pem = String(privateKeyPem || '') .replace(/\\n/g, '\n') .replace(/\\r/g, '\r') .trim();
if (!pem) { throw 'Private key PEM is empty'; }
if (pem.indexOf('-----BEGIN PRIVATE KEY-----') === 0) { return this._loadPkcs8PrivateKey(pem); }
if (pem.indexOf('-----BEGIN EC PRIVATE KEY-----') === 0) { var keyBytes = this._pemToByteArray( pem, '-----BEGIN EC PRIVATE KEY-----', '-----END EC PRIVATE KEY-----' );
try { return this._loadPkcs8DerBytes(keyBytes); } catch (pkcs8Error) { return this._loadSec1EcPrivateKeyBytes(keyBytes); } }
throw 'Unsupported private key format. Expected BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY.'; },
_loadPkcs8PrivateKey: function(pem) { var keyBytes = this._pemToByteArray( pem, '-----BEGIN PRIVATE KEY-----', '-----END PRIVATE KEY-----' );
return this._loadPkcs8DerBytes(keyBytes); },
_loadPkcs8DerBytes: function(bytes) { var keySpec = new Packages.java.security.spec.PKCS8EncodedKeySpec( this._toJavaByteArray(bytes) );
var keyFactory = Packages.java.security.KeyFactory.getInstance('EC');
return keyFactory.generatePrivate(keySpec); },
_loadSec1EcPrivateKeyBytes: function(sec1Bytes) { var privateScalarBytes = this._extractSec1PrivateScalar(sec1Bytes);
var BigInteger = Packages.java.math.BigInteger; var privateScalar = new BigInteger(1, this._toJavaByteArray(privateScalarBytes));
var AlgorithmParameters = Packages.java.security.AlgorithmParameters; var ECGenParameterSpec = Packages.java.security.spec.ECGenParameterSpec; var ECParameterSpec = Packages.java.security.spec.ECParameterSpec; var ECPrivateKeySpec = Packages.java.security.spec.ECPrivateKeySpec; var KeyFactory = Packages.java.security.KeyFactory;
var params = AlgorithmParameters.getInstance('EC'); params.init(new ECGenParameterSpec('secp256r1'));
var ecSpec = params.getParameterSpec(ECParameterSpec); var privateKeySpec = new ECPrivateKeySpec(privateScalar, ecSpec);
return KeyFactory.getInstance('EC').generatePrivate(privateKeySpec); },
_extractSec1PrivateScalar: function(bytes) { var offset = 0;
if (bytes[offset++] !== 0x30) { throw 'Invalid SEC1 EC key. Expected DER SEQUENCE. Found tag: 0x' + this._toHex(bytes[offset - 1]); }
var seqLenInfo = this._readDerLength(bytes, offset); offset = seqLenInfo.nextOffset;
if (bytes[offset++] !== 0x02) { throw 'Invalid SEC1 EC key. Expected INTEGER version. Found tag: 0x' + this._toHex(bytes[offset - 1]); }
var versionLenInfo = this._readDerLength(bytes, offset); offset = versionLenInfo.nextOffset + versionLenInfo.length;
var tag = bytes[offset];
if (tag !== 0x04) { throw 'Invalid SEC1 EC key. Expected OCTET STRING private key. Found tag: 0x' + this._toHex(tag) + ' at offset ' + offset; }
offset++;
var privateLenInfo = this._readDerLength(bytes, offset); offset = privateLenInfo.nextOffset;
var privateBytes = [];
for (var i = 0; i < privateLenInfo.length; i++) { privateBytes.push(bytes[offset + i]); }
if (privateBytes.length === 0) { throw 'Invalid SEC1 EC key. Private scalar is empty.'; }
while (privateBytes.length < 32) { privateBytes.unshift(0); }
if (privateBytes.length > 32) { throw 'Invalid SEC1 EC key. Private scalar is longer than 32 bytes: ' + privateBytes.length; }
return privateBytes; },
_pemToByteArray: function(pem, beginMarker, endMarker) { var cleaned = String(pem || '') .replace(beginMarker, '') .replace(endMarker, '') .replace(/\r/g, '') .replace(/\n/g, '') .replace(/\s/g, '');
if (!cleaned) { throw 'Private key PEM is empty after removing header/footer'; }
var invalidMatch = cleaned.match(/[^A-Za-z0-9+/=]/);
if (invalidMatch) { throw 'Private key contains invalid Base64 character "' + invalidMatch[0] + '" at position ' + invalidMatch.index; }
var decoded = Packages.java.util.Base64.getDecoder().decode(cleaned); var bytes = [];
for (var i = 0; i < decoded.length; i++) { var value = Number(decoded[i]);
if (value < 0) { value += 256; }
bytes.push(value); }
return bytes; },
_readDerLength: function(bytes, offset) { var first = bytes[offset++];
if (first < 0x80) { return { length: first, nextOffset: offset }; }
var byteCount = first & 0x7f; var length = 0;
for (var i = 0; i < byteCount; i++) { length = (length << 8) + bytes[offset++]; }
return { length: length, nextOffset: offset }; },
_toJavaByteArray: function(bytes) { var ByteType = Packages.java.lang.Byte.TYPE; var output = Packages.java.lang.reflect.Array.newInstance(ByteType, bytes.length);
for (var i = 0; i < bytes.length; i++) { var value = Number(bytes[i]);
if (value > 127) { value = value - 256; }
output[i] = value; }
return output; },
_toHex: function(value) { value = Number(value);
if (value < 0) { value += 256; }
var hex = value.toString(16).toUpperCase();
if (hex.length < 2) { hex = '0' + hex; }
return hex; },
type: 'ABMMidAppleToken'};
ABMAuth (Script include)
var ABMAuth = Class.create();ABMAuth.prototype = { initialize: function() { this.credentialSysId = gs.getProperty('abm.credential_sys_id');
/* * For Apple Business Manager, client_id and team_id are the same * for this implementation. */ this.teamId = gs.getProperty('abm.team_id'); this.clientId = gs.getProperty('abm.team_id');
this.keyId = gs.getProperty('abm.key_id'); this.midServerName = gs.getProperty('abm.mid_server_name');
/* * Confirmed scope for Apple Business Manager. */ this.scope = 'business.api';
this.responseTimeoutSeconds = 60;
/* * If the cached token expires within the next 5 minutes, * treat it as expired and refresh it. */ this.tokenSafetyBufferSeconds = 300;
this.cacheTokenProperty = 'abm.cached_access_token'; this.cacheExpiresAtProperty = 'abm.cached_access_token_expires_at'; },
getAccessToken: function(forceRefresh) { this._validateProperties();
if (forceRefresh !== true) { var cachedToken = this._getCachedAccessToken();
if (cachedToken) { return cachedToken; } }
return this._refreshAccessToken(); },
clearCachedAccessToken: function() { this._setProperty(this.cacheTokenProperty, ''); this._setProperty(this.cacheExpiresAtProperty, '');
gs.info('ABM cached access token cleared.'); },
_refreshAccessToken: function() { var credential = this._getCredential(); var clientAssertion = this._getClientAssertionFromMid(credential); var tokenResponse = this._requestAppleAccessTokenResponse(credential.client_id, clientAssertion);
this._storeCachedAccessToken( tokenResponse.access_token, parseInt(tokenResponse.expires_in, 10) || 3600 );
return tokenResponse.access_token; },
_getCachedAccessToken: function() { var token = gs.getProperty(this.cacheTokenProperty, '');
if (!token) { return ''; }
var expiresAtValue = gs.getProperty(this.cacheExpiresAtProperty, '');
if (!expiresAtValue) { return ''; }
var expiresAt;
try { expiresAt = new GlideDateTime(expiresAtValue); } catch (e) { gs.warn('ABM cached token expiry could not be parsed. Refreshing token. Value: ' + expiresAtValue); return ''; }
var usableUntil = new GlideDateTime(); usableUntil.addSeconds(this.tokenSafetyBufferSeconds);
if (!expiresAt.after(usableUntil)) { return ''; }
return token; },
_storeCachedAccessToken: function(accessToken, expiresInSeconds) { if (!accessToken) { throw 'Cannot store empty ABM access token'; }
var safeExpiresIn = parseInt(expiresInSeconds, 10) || 3600;
/* * Store the usable expiry minus the safety buffer. * Example: Apple returns 3600 seconds. * Stored expiry becomes now + 3300 seconds. */ var expiresAt = new GlideDateTime(); expiresAt.addSeconds(safeExpiresIn - this.tokenSafetyBufferSeconds);
this._setProperty(this.cacheTokenProperty, accessToken); this._setProperty(this.cacheExpiresAtProperty, expiresAt.getValue());
gs.info('ABM access token cached in system properties. Expires at: ' + expiresAt.getDisplayValue()); },
_setProperty: function(name, value) { var prop = new GlideRecord('sys_properties'); prop.addQuery('name', name); prop.setLimit(1); prop.query();
if (prop.next()) { prop.setValue('value', value); prop.update(); return; }
prop.initialize(); prop.setValue('name', name); prop.setValue('value', value); prop.insert(); },
_getCredential: function() { var gr = new GlideRecord('discovery_credentials');
if (!gr.get(this.credentialSysId)) { throw 'ABM credential not found: ' + this.credentialSysId; }
if (gr.getValue('active') == 'false') { throw 'ABM credential is inactive: ' + this.credentialSysId; }
var privateKeyPem = '';
try { privateKeyPem = String(gr.password.getDecryptedValue() || ''); } catch (e) { throw 'Unable to decrypt ABM credential password using getDecryptedValue(): ' + e; }
privateKeyPem = privateKeyPem .replace(/\\n/g, '\n') .replace(/\\r/g, '\r') .trim();
if (!privateKeyPem) { throw 'ABM credential password/private key is empty after decryption'; }
if ( privateKeyPem.indexOf('-----BEGIN EC PRIVATE KEY-----') !== 0 && privateKeyPem.indexOf('-----BEGIN PRIVATE KEY-----') !== 0 ) { throw 'Unsupported ABM private key format. Expected BEGIN EC PRIVATE KEY or BEGIN PRIVATE KEY.'; }
return { client_id: this.clientId, private_key_pem: privateKeyPem }; },
_getClientAssertionFromMid: function(credential) { var probeName = 'ABM Apple Client Assertion Request ' + gs.generateGUID();
var jsProbe = new JavascriptProbe(this.midServerName); jsProbe.setName(probeName);
jsProbe.addParameter('client_id', credential.client_id); jsProbe.addParameter('team_id', this.teamId); jsProbe.addParameter('key_id', this.keyId); jsProbe.addParameter('private_key_pem', credential.private_key_pem);
jsProbe.setJavascript( "var tokenGenerator = new ABMMidAppleToken(); tokenGenerator.getClientAssertion();" );
var eccSysId = jsProbe.create(); var result = this._waitForProbeResult(eccSysId);
if (!result.success) { throw result.error_message || 'MID client assertion generation failed'; }
if (!result.client_assertion) { throw 'MID response did not include client_assertion'; }
return result.client_assertion; },
_requestAppleAccessTokenResponse: function(clientId, clientAssertion) { var request = new sn_ws.RESTMessageV2(); request.setEndpoint('https://account.apple.com/auth/oauth2/token'); request.setHttpMethod('POST'); request.setRequestHeader('Host', 'account.apple.com'); request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); request.setRequestHeader('Accept', 'application/json');
var body = [ 'grant_type=' + encodeURIComponent('client_credentials'), 'client_id=' + encodeURIComponent(clientId), 'client_assertion_type=' + encodeURIComponent('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'), 'client_assertion=' + encodeURIComponent(clientAssertion), 'scope=' + encodeURIComponent(this.scope) ].join('&');
request.setRequestBody(body);
var response = request.execute(); var status = response.getStatusCode(); var responseBody = response.getBody();
gs.info('ABM Apple token response status: ' + status);
if (status < 200 || status >= 300) { gs.error('ABM Apple token response body: ' + responseBody); throw 'Apple token request failed. HTTP ' + status + ': ' + responseBody; }
var parsed = JSON.parse(responseBody);
if (!parsed.access_token) { throw 'Apple token response did not include access_token: ' + responseBody; }
return parsed; },
_waitForProbeResult: function(outputEccSysId) { var deadline = new GlideDateTime(); deadline.addSeconds(this.responseTimeoutSeconds);
while (new GlideDateTime().before(deadline)) { var input = new GlideRecord('ecc_queue'); input.addQuery('queue', 'input'); input.addQuery('response_to', outputEccSysId); input.orderByDesc('sys_created_on'); input.setLimit(1); input.query();
if (input.next()) { var payload = String(input.getValue('payload') || ''); return this._parseProbePayload(payload); }
gs.sleep(1000); }
throw 'Timed out waiting for MID client assertion response after ' + this.responseTimeoutSeconds + ' seconds. ECC output sys_id: ' + outputEccSysId; },
_parseProbePayload: function(payload) { if (!payload) { return { success: false, error_message: 'MID returned empty ECC payload' }; }
/* * JavascriptProbe payloads can vary slightly by release. * Try direct JSON first, then extract JSON from the payload. */ try { return JSON.parse(payload); } catch (ignoreDirectJson) { // Continue to JSON extraction. }
var match = payload.match(/\{[\s\S]*\}/);
if (!match) { return { success: false, error_message: 'Could not find JSON object in MID ECC payload: ' + payload }; }
try { return JSON.parse(match[0]); } catch (e) { return { success: false, error_message: 'Could not parse MID JSON response: ' + e + '. Payload: ' + payload }; } },
_validateProperties: function() { var missing = [];
if (!this.credentialSysId) { missing.push('abm.credential_sys_id'); }
if (!this.teamId) { missing.push('abm.team_id'); }
if (!this.clientId) { missing.push('abm.team_id used as client_id'); }
if (!this.keyId) { missing.push('abm.key_id'); }
if (!this.midServerName) { missing.push('abm.mid_server_name'); }
if (missing.length > 0) { throw 'Missing ABM configuration properties: ' + missing.join(', '); } },
type: 'ABMAuth'};