← CCSP: cloud security, data, and operations
10 / 16 · 55 MIN

Tokens: trust and tenant authorization

Validate access tokens and test object permissions, actions and key transitions.

Trust contract before using claims

A fictional API receives an access token for reading batches. Before using tenant or scope, define the accepted issuer, this API’s identifier, the token profile and the authorized key source. The example accepts only RS256 with preconfigured public keys. kid selects one of those keys; it does not confer trust on a client-supplied key. Headers containing key URLs are rejected in this subset, with no network requests. In a real integration, document discovery and JWKS refresh through trusted configuration. An unavailable key endpoint is an operational problem to diagnose, not a reason to disable signature validation.

Each validation answers a different question

The script’s second group signs test tokens and checks signature, access-token type, issuer, audience and time. It uses a fixed clock to repeat conditions. Signed tokens with the wrong audience, expired validity or future activation are rejected. An ID token does not replace the access token this API requires. Local policy also requires appropriate mandatory-claim types and rejects future iat without leeway; a real integration should define clock tolerance explicitly. The signature protects field integrity but does not conceal fields: do not put secrets in the payload of a merely signed JWT. Never log the complete token to simplify troubleshooting.

Authorize the object and action on every request

After validation, authorize compares the trusted token tenant with the tenant of the server-maintained object. It also requires sub to be among that object’s readers, the batch:read scope to be present and the action to be read. Missing any condition produces denial. The checks show that a valid token cannot read another tenant’s object or write using read permission. Do not treat a client-supplied tenant parameter as proof of object ownership. This example uses an in-memory record; a subsequent real-application exercise should include alternative endpoints, database queries, permission changes and concurrent requests.

Plan the signing-key transition

The exercise starts with two trusted public keys. During overlap, tokens signed with old and new are accepted. Once old leaves the trusted set, the old token is rejected even though it has not expired. During a normal transition, coordinate issuance, publication, caches and token validity to avoid unnecessary interruption. During confirmed compromise, waiting for expiry may preserve unauthorized access; containment decisions must consider risk and impact. Removing a key from this Map does not update remote caches or revoke sessions in every service. Give operations the owners, escalation criteria and rejection evidence from relevant consumers.

Run, observe and bound the conclusion

Save the code as run.mjs and run node run.mjs on Node.js 25.8.0. No cloud accounts, additional packages or credentials are needed. The JSON report contains 36 expected and observed outcomes; any difference ends execution with an error. The token group has 24 checks. The script is not a production JWT library: duplicate JSON member rejection, discovery, revocation, introspection and replay prevention are not implemented. A stolen bearer token may remain usable during its validity without additional controls. Summarize acceptance in three parts: token integrity and profile, object and action authorization, and operational behavior during faults. Connect the conclusions with IAM, secure APIs and incident response.

// Original BigSavant teaching exercise. Synthetic data; no network or credentials.
// This deliberately limited JWT verifier is not a production OAuth/JWT library.
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createCipheriv,createDecipheriv,randomBytes,generateKeyPairSync,sign,verify,createHash,constants} from 'node:crypto'
const checks=[];
function check(name,inputs,actual,expected){assert.deepEqual(actual,expected,name);checks.push({name,inputs,actual,expected});}
function outcome(fn){try{fn;return 'accepted'}catch{return 'rejected'}}
function seal(key,plaintext,aad){
 const iv=randomBytes(12),c=createCipheriv('aes-256-gcm',key,iv,{authTagLength:16});
 c.setAAD(Buffer.from(aad));const ciphertext=Buffer.concat([c.update(plaintext),c.final]);
 return {iv,ciphertext,tag:c.getAuthTag};
}
function open(key,envelope,aad){
 const d=createDecipheriv('aes-256-gcm',key,envelope.iv,{authTagLength:16});
 d.setAAD(Buffer.from(aad));d.setAuthTag(envelope.tag);
 // Never return unauthenticated output from update before final succeeds.
 return Buffer.concat([d.update(envelope.ciphertext),d.final]);
}
const payload=Buffer.from('SYNTHETIC fund A settlement batch 17'),context='tenant=A;object=batch-17;version=1'
const dek=randomBytes(32),oldKek=randomBytes(32),newKek=randomBytes(32),registry=new Map([['old',oldKek],['new',newKek]]);
const data=seal(dek,payload,context),wrapContext='purpose=data-key;tenant=A'
const oldWrap={keyId:'old',...seal(oldKek,dek,wrapContext)};
function unwrap(w){const key=registry.get(w.keyId);if(!key)throw Error('key unavailable');return open(key,w,wrapContext);}
function restore(w){return open(unwrap(w),data,context).equals(payload);}
check('baseline envelope restoration',{wrapper:'old',context},restore(oldWrap),true);
const changed=Buffer.from(data.ciphertext);changed[0]^=1;
check('changed ciphertext rejected',{mutation:'first ciphertext byte'},outcome(=>open(dek,{...data,ciphertext:changed},context)),'rejected');
const changedTag=Buffer.from(data.tag);changedTag[0]^=1;
check('changed tag rejected',{mutation:'first tag byte'},outcome(=>open(dek,{...data,tag:changedTag},context)),'rejected');
check('wrong context rejected',{context:'tenant=B;object=batch-17;version=1'},outcome(=>open(dek,data,'tenant=B;object=batch-17;version=1')),'rejected');
check('wrong wrapping key rejected',{wrapper:'old',key:'new'},outcome(=>open(newKek,oldWrap,wrapContext)),'rejected');
const before=createHash('sha256').update(data.ciphertext).digest('hex');
const newWrap={keyId:'new',...seal(newKek,unwrap(oldWrap),wrapContext)};
check('rewrapped data key restores payload',{wrapper:'new'},restore(newWrap),true);
check('rewrap leaves data ciphertext unchanged',{operation:'rewrap same DEK'},createHash('sha256').update(data.ciphertext).digest('hex')===before,true);
check('old backup still uses old wrapper',{wrapper:'old'},restore(oldWrap),true);
registry.delete('old'); // Logical availability fault, NOT erasure from memory.
check('old backup fails when old key unavailable',{operation:'delete registry entry only'},outcome(=>restore(oldWrap)),'rejected');
check('new wrapper survives old key unavailability',{wrapper:'new'},restore(newWrap),true);
registry.set('old',oldKek);
check('restoring registry entry restores old backup',{operation:'restore registry entry'},restore(oldWrap),true);
check('retained data key still decrypts after rewrap',{operation:'use original DEK directly'},open(dek,data,context).equals(payload),true);

const now=1800000000,issuer='https://issuer.example.invalid/',audience='funds-api'
const pairs={old:generateKeyPairSync('rsa',{modulusLength:2048}),new:generateKeyPairSync('rsa',{modulusLength:2048})};
const trusted=new Map(Object.entries(pairs).map(([id,p])=>[id,{key:p.publicKey,alg:'RS256'}]));
const claims={iss:issuer,aud:audience,sub:'operator-7',client_id:'batch-client',iat:now-30,exp:now+300,jti:'synthetic-17',tenant:'A',scope:'batch:read'};
const encode=x=>Buffer.from(JSON.stringify(x)).toString('base64url');
function issue(body=claims,kid='old',header={}){
 const prefix=encode({typ:'at+jwt',alg:'RS256',kid,...header})+'.'+encode(body);
 return prefix+'.'+sign('RSA-SHA256',Buffer.from(prefix),{key:pairs[kid].privateKey,padding:constants.RSA_PKCS1_PADDING}).toString('base64url');
}
function validate(token){
 if(typeof token!=='string'||token.length>8192)throw Error('size');
 const parts=token.split('.');if(parts.length!==3||parts.some(p=>!p||!/^[A-Za-z0-9_-]+$/.test(p)))throw Error('format');
 const decode=p=>JSON.parse(Buffer.from(p,'base64url').toString('utf8'));
 const h=decode(parts[0]),c=decode(parts[1]);
 if(!h||Array.isArray(h)||!c||Array.isArray(c))throw Error('object');
 // Local policy supports only the signed access-token subset demonstrated here.
 if(h.alg!=='RS256'||!['at+jwt','application/at+jwt'].includes(h.typ))throw Error('profile');
 if(['jku','x5u','jwk','crit','b64'].some(k=>Object.hasOwn(h,k)))throw Error('unsupported header');
 const trust=trusted.get(h.kid);if(!trust||trust.alg!==h.alg)throw Error('untrusted key');
 if(!verify('RSA-SHA256',Buffer.from(parts[0]+'.'+parts[1]),{key:trust.key,padding:constants.RSA_PKCS1_PADDING},Buffer.from(parts[2],'base64url')))throw Error('signature');
 for(const k of ['iss','sub','client_id','jti'])if(typeof c[k]!=='string'||!c[k])throw Error('required claim');
 if(c.iss!==issuer)throw Error('issuer');
 const audiences=typeof c.aud==='string'?[c.aud]:c.aud;
 if(!Array.isArray(audiences)||audiences.some(a=>typeof a!=='string')||!audiences.includes(audience))throw Error('audience');
 if(!Number.isFinite(c.iat)||!Number.isFinite(c.exp)||c.exp<=now||c.iat>now||c.exp<=c.iat)throw Error('time');
 if(c.nbf!==undefined&&(!Number.isFinite(c.nbf)||c.nbf>now))throw Error('not yet valid');
 return c;
}
const record={id:'batch-17',tenant:'A',readers:['operator-7']};
function authorize(c,object,action){
 return action==='read'&&c.tenant===object.tenant&&object.readers.includes(c.sub)&&typeof c.scope==='string'&&c.scope.split(' ').includes('batch:read');
}
const valid=issue;
check('valid access token accepted',{kid:'old',audience},outcome(=>validate(valid)),'accepted');
for(const [name,body] of [
 ['wrong issuer rejected',{...claims,iss:'https://other.example.invalid/'}],
 ['wrong audience rejected',{...claims,aud:'other-api'}],
 ['expired token rejected',{...claims,exp:now}],
 ['future activation rejected',{...claims,nbf:now+60}],
 ['future issue time rejected',{...claims,iat:now+1}],
 ['missing subject rejected',{...claims,sub:undefined}],
 ['missing client rejected',{...claims,client_id:undefined}],
 ['non-numeric expiry rejected',{...claims,exp:String(now+300)}]
])check(name,{variation:name},outcome(=>validate(issue(body))),'rejected');
const pieces=valid.split('.');pieces[1]=encode({...claims,tenant:'B'});
check('changed claims rejected',{mutation:'tenant without resigning'},outcome(=>validate(pieces.join('.'))),'rejected');
for(const [name,header] of [
 ['unsigned algorithm rejected',{alg:'none'}],
 ['ID token type rejected',{typ:'JWT'}],
 ['unknown key identifier rejected',{kid:'attacker'}],
 ['token key URL rejected',{jku:'https://keys.example.invalid/jwks'}]
])check(name,{header},outcome(=>validate(issue(claims,'old',header))),'rejected');
check('audience array accepted',{aud:['other-api',audience]},outcome(=>validate(issue({...claims,aud:['other-api',audience]}))),'accepted');
check('authorized resource read',{record:'batch-17',action:'read'},authorize(validate(valid),record,'read'),true);
check('valid token cannot cross tenant',{recordTenant:'B'},authorize(validate(valid),{...record,tenant:'B'},'read'),false);
check('valid token requires object membership',{readers:['operator-8']},authorize(validate(valid),{...record,readers:['operator-8']},'read'),false);
check('read scope does not grant write',{action:'write'},authorize(validate(valid),record,'write'),false);
check('missing scope denies access',{scope:''},authorize(validate(issue({...claims,scope:''})),record,'read'),false);
const newToken=issue(claims,'new');
check('new signing key accepted during overlap',{kid:'new'},outcome(=>validate(newToken)),'accepted');
check('old signing key accepted during overlap',{kid:'old'},outcome(=>validate(valid)),'accepted');
trusted.delete('old');
check('retired trusted key rejects unexpired token',{kid:'old',expiresIn:300},outcome(=>validate(valid)),'rejected');
check('new signing key survives retirement',{kid:'new'},outcome(=>validate(newToken)),'accepted');
console.log(JSON.stringify({checkedAt:new Date.toISOString,runtime:process.version,openssl:process.versions.openssl,scriptSha256:createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex'),groups:['AES-256-GCM envelope operations','RS256 access-token validation and local authorization'],network:false,checks,limitations:[
 'Actual local cryptographic operations, not AWS KMS, HSM or provider IAM execution; no FIPS-validation claim.',
 'Registry deletion is logical key unavailability, not memory erasure or cryptographic sanitization.',
 'Teaching JWT subset with fixed clock and preconfigured keys; not a production parser or OAuth/OIDC implementation. Duplicate JSON member rejection, discovery, JWKS cache, introspection, revocation, replay prevention, DPoP and mTLS are not implemented.',
 'No provider outage, production application, concurrent request or tenant database test; all records and claims are synthetic.'
]},null,2))
IN PRACTICE

Synthetic settlement-batch data; no BNP Paribas internal information.

Common pitfalls

Accepting an isolated technical indicator as proof of the complete control; omitting negative tests and dependencies.

Related topics: Cloud recovery and incidents · Data protection and IAM

Take this idea with you

Validate access tokens and test object permissions, actions and key transitions.

Create account

Reference: JWT Profile for OAuth 2.0 Access Tokens · CCSP examination outline effective 2026-08-01; January2026 V2 PDF

CCSP® is a registered trademark of ISC2, Inc. bigsavant.com is an independent preparation platform and is not affiliated with, associated with, sponsored, authorised or endorsed by ISC2. Content and questions are original, are not official exam questions, and completing our tests does not award or guarantee any certification. Names are used only to identify the subject. All other trademarks belong to their respective owners.