1. Separate identity, profile and posture
A laptop may present an accepted certificate while lacking current evidence of required antimalware or configuration. Authentication identifies a credential or peer; profiling and inventory help classify the device; posture evaluates concrete requirements. In the studied ISE guide, unknown can mean no matching policy or no received assessment. Do not automatically translate it into malware. Noncompliant refers to unmet requirements and needs diagnosis and remediation. Compliant must be read with policy, requirements, time and context rather than as assurance of no compromise. Design three authorization outcomes and the resources needed for remediation. An overly broad access rule can neutralize a correct posture assessment.
2. Understand result age
Posture lease can reuse the last known state and does not imply querying the endpoint again at every access. In the documented flow, lease expiry does not itself trigger reauthentication or reassessment of an existing session. Distinguish it from periodic reassessment, applicable to already-compliant clients, and check method support and failover behavior. A grace period can temporarily authorize a previously compliant device despite a current failure; this is not evidence of renewed compliance. Record accepted risk, expiry and reachable services. For agentless posture, “no permanent agent” does not mean “no endpoint code”: the flow can contact the client, run a plug-in and remove it. Credentials, connectivity and accounting events belong in diagnosis.
3. Observe actual mutual authentication
Run the lab with Node 25.8.0 and the recorded certificate generator. The server listens only on 127.0.0.1, requires a client certificate and validates the supplied CA; the client also validates the server CA and access.test name. Test a missing certificate, untrusted issuer, serverAuth-only certificate and wrong server name. These requests do not reach the HTTP handler. These are actual TLS 1.3 connections, not a handshake model. Certificates and keys are created in an owned temporary directory with restricted permissions and removed afterward. No system trust store changes occur. Execution includes no EAP, RADIUS, ISE, posture scan or revocation checking.
4. Apply policy after the certificate
A fictional fingerprint-indexed registry binds a credential to a role and posture data. A new key with the same CN has another fingerprint and is not automatically authorized. A client registered as reader receives 403 for /admin even though mTLS passes. Unknown, noncompliant and stale evidence also receive separate decisions. The 60-second limit and fixed clock are teaching choices in this code, not ISE values. The /remediate route is explicitly allowed for the registered client to illustrate limited access. Changing registry data does not measure the endpoint. Removing an identity from the registry returns 403 while its certificate remains cryptographically accepted: that is not PKI revocation.
5. Validate the access transition
For a real deployment, test authentication, identity binding, posture, authorization rule, network-device enforcement and final access. Plan certificate renewal with binding policy and recovery; an equal CN does not establish that the new artifact is authorized. Confirm revocation requirements and access to necessary services before claiming EAP-TLS conformance. RFC 9190 requires checks this lab does not perform. Collect allowed, denied and remediation outcomes, including existing sessions and new authentication, with versions and owners. Summary: a valid certificate, current posture and effective authorization need separate evidence. The 28 local checks do not replace acceptance of supplicant, NAD, ISE and the organization’s operational requirements.
// Actual loopback mutual TLS; posture and role decisions are explicit synthetic data.
import fs from 'node:fs'import os from 'node:os'import path from 'node:path'import https from 'node:https'import {X509Certificate,createHash}from'node:crypto'import{execFileSync}from'node:child_process'import assert from'node:assert/strict'
const dir=fs.mkdtempSync(path.join(os.tmpdir,'dr-mtls-posture-'));fs.chmodSync(dir,0o700);const openssl='/usr/bin/openssl',checks=[],outcomes=[],sockets=new Set;let server,applicationRequests=0,now=1000;
const check=(name,ok)=>{assert(ok,name);checks.push(name);},read=n=>fs.readFileSync(path.join(dir,n)),write=(n,s)=>fs.writeFileSync(path.join(dir,n),s),command=args=>execFileSync(openssl,args,{cwd:dir,stdio:['ignore','pipe','pipe'],timeout:15000});
function root(n){write(n+'.cnf',`[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ca\n[dn]\nCN=fictional-${n}\n[ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n`);command(['req','-x509','-newkey','rsa:2048','-nodes','-days','2','-sha256','-config',n+'.cnf','-keyout',n+'.key','-out',n+'.pem']);fs.chmodSync(path.join(dir,n+'.key'),0o600);}
function leaf(n,issuer,serial,cn,usage){command(['req','-new','-newkey','rsa:2048','-nodes','-sha256','-subj','/CN='+cn,'-keyout',n+'.key','-out',n+'.csr']);write(n+'.ext',`basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=${usage}\nsubjectAltName=DNS:${cn}\n`);command(['x509','-req','-in',n+'.csr','-CA',issuer+'.pem','-CAkey',issuer+'.key','-set_serial',String(serial),'-days','1','-sha256','-extfile',n+'.ext','-out',n+'.pem']);fs.chmodSync(path.join(dir,n+'.key'),0o600);return {key:read(n+'.key'),cert:read(n+'.pem')};}
try{
root('rootA');root('rootB');const srv=leaf('server','rootA',10,'access.test','serverAuth'),device=leaf('device','rootA',20,'device.test','clientAuth'),replacement=leaf('replacement','rootA',21,'device.test','clientAuth'),foreign=leaf('foreign','rootB',22,'device.test','clientAuth'),wrongPurpose=leaf('wrong-purpose','rootA',23,'device.test','serverAuth');
const fingerprint=material=>new X509Certificate(material.cert).fingerprint256,id=fingerprint(device),newId=fingerprint(replacement);
const registry=new Map([[id,{role:'reader',posture:'compliant',observedAt:990}]]);
server=https.createServer({...srv,ca:read('rootA.pem'),requestCert:true,rejectUnauthorized:true,minVersion:'TLSv1.3',maxVersion:'TLSv1.3'},(req,res)=>{
applicationRequests++;const peer=req.socket.getPeerCertificate,entry=registry.get(peer.fingerprint256);let status=200,reason='allowed'
if(!entry){status=403;reason='unregistered-certificate'}
else if(req.url==='/remediate'){reason='limited-remediation'}
else if(entry.posture!=='compliant'){status=403;reason='posture-'+entry.posture;}
else if(now-entry.observedAt>60){status=403;reason='stale-posture'}
else if(req.url!=='/report'){status=403;reason='role-denied'}
res.writeHead(status,{'Content-Type':'application/json',Connection:'close'});res.end(JSON.stringify({reason,clientCertificateAuthorized:req.socket.authorized,clientCN:peer.subject.CN}));
});server.on('tlsClientError',=>{});server.on('connection',s=>{sockets.add(s);s.on('close',=>sockets.delete(s));});
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
async function request(label,material=device,options={}){const result=await new Promise(resolve=>{const r=https.get({host:'127.0.0.1',port:server.address.port,path:options.path||'/report',servername:options.name||'access.test',ca:options.ca||read('rootA.pem'),...(material||{}),minVersion:'TLSv1.3',maxVersion:'TLSv1.3',rejectUnauthorized:true,agent:false},res=>{const protocol=res.socket.getProtocol,serverAuthorized=res.socket.authorized,reused=res.socket.isSessionReused;let text=''res.on('data',d=>text+=d);res.on('end',=>resolve({ok:true,status:res.statusCode,protocol,serverAuthorized,reused,...JSON.parse(text)}));res.on('error',e=>resolve({ok:false,error:e.code||e.message}));});r.setTimeout(4000,=>r.destroy(Error('timeout')));r.on('error',e=>resolve({ok:false,error:e.code||e.message}));});outcomes.push({label,...result});return result;}
check('listener binds only loopback',server.address.address==='127.0.0.1');
const allowed=await request('trusted-registered-current');check('trusted registered client reaches report',allowed.status===200&&allowed.reason==='allowed');check('TLS1.3 actually negotiated',allowed.protocol==='TLSv1.3');check('client validates server certificate',allowed.serverAuthorized===true);check('server validates client certificate',allowed.clientCertificateAuthorized===true);
let count=applicationRequests;const absent=await request('missing-certificate',null);check('missing client certificate rejected before HTTP',!absent.ok&&applicationRequests===count);
const otherCA=await request('untrusted-client-issuer',foreign);check('untrusted client issuer rejected before HTTP',!otherCA.ok&&applicationRequests===count);
const purpose=await request('wrong-client-purpose',wrongPurpose);check('server-only certificate rejected for client authentication',!purpose.ok&&applicationRequests===count);
const badServerTrust=await request('untrusted-server-issuer',device,{ca:read('rootB.pem')});check('untrusted server issuer rejected before HTTP',!badServerTrust.ok&&applicationRequests===count);
const badName=await request('wrong-server-name',device,{name:'other.test'});check('wrong expected server identity rejected',!badName.ok&&badName.error==='ERR_TLS_CERT_ALTNAME_INVALID'&&applicationRequests===count);
const newUnregistered=await request('replacement-not-enrolled',replacement);check('same-CN replacement has different certificate identity',newId!==id&&new X509Certificate(device.cert).subject===new X509Certificate(replacement.cert).subject);check('valid replacement chain does not create registry authorization',newUnregistered.clientCertificateAuthorized===true&&newUnregistered.status===403&&newUnregistered.reason==='unregistered-certificate');
const wrongRole=await request('reader-admin-path',device,{path:'/admin'});check('valid mutual TLS does not grant administrative role',wrongRole.status===403&&wrongRole.reason==='role-denied'&&wrongRole.clientCertificateAuthorized);
registry.get(id).posture='unknown'const unknown=await request('unknown-posture');check('unknown posture is distinct from failed TLS',unknown.status===403&&unknown.reason==='posture-unknown'&&unknown.clientCertificateAuthorized);
registry.get(id).posture='noncompliant'const badPosture=await request('noncompliant-posture');check('noncompliant posture does not invalidate the certificate',badPosture.status===403&&badPosture.reason==='posture-noncompliant'&&badPosture.clientCertificateAuthorized);
const remediation=await request('limited-remediation',device,{path:'/remediate'});check('registered noncompliant client can use only the configured remediation path',remediation.status===200&&remediation.reason==='limited-remediation');
registry.get(id).posture='compliant'now=1050;const boundary=await request('freshness-boundary');check('synthetic 60-second boundary remains accepted',boundary.status===200);
now=1051;const stale=await request('stale-evidence');check('stale posture denied despite valid certificate',stale.status===403&&stale.reason==='stale-posture'&&stale.clientCertificateAuthorized);
registry.get(id).observedAt=now;const refreshed=await request('fresh-evidence');check('new synthetic evidence restores the allowed report path',refreshed.status===200);
registry.set(newId,{role:'reader',posture:'compliant',observedAt:now});const enrolled=await request('replacement-enrolled',replacement);check('controlled enrollment permits the replacement client',enrolled.status===200&&enrolled.clientCertificateAuthorized);
registry.delete(id);const retired=await request('old-identity-removed');check('registry removal denies old identity at application layer',retired.status===403&&retired.reason==='unregistered-certificate');check('registry removal is not PKI revocation',retired.clientCertificateAuthorized===true);
check('all successful HTTPS requests use fresh TLS sessions',outcomes.filter(o=>o.ok).every(o=>!o.reused));check('eleven requests reach application',applicationRequests===11);check('sixteen connections attempted',outcomes.length===16);
const keys=fs.readdirSync(dir).filter(f=>f.endsWith('.key'));check('all generated private keys have owner-only permissions',keys.every(f=>(fs.statSync(path.join(dir,f)).mode&0o777)===0o600));
for(const s of sockets)s.destroy;await new Promise(resolve=>server.close(resolve));check('listener closed after exercise',!server.listening);server=null;
}finally{for(const s of sockets)s.destroy;if(server?.listening)await new Promise(resolve=>server.close(resolve));fs.rmSync(dir,{recursive:true,force:true});}
check('owned temporary certificate directory removed',!fs.existsSync(dir));
console.log(JSON.stringify({passed:checks.length,failed:0,checks,node:process.version,nodeOpenSSL:process.versions.openssl,certificateTool:execFileSync(openssl,['version'],{encoding:'utf8'}).trim,scriptSHA256:createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex'),actualMutualTLS:true,actualTLS13:true,actualCiscoISE:false,actualEAP:false,actualRADIUS:false,actualPostureScan:false,postureDataFictional:true,revocationChecked:false,systemTrustStoreChanged:false,externalConnections:false,observations:{applicationRequests,connections:outcomes.length,postureAgeLimitSeconds:60,postureClock:'synthetic fixed values',outcomes},scope:'Actual loopback mutual TLS only. Original in-memory fingerprint registry, role and posture decisions are fictional application policy, not ISE or EAP-TLS conformance. No certificate-revocation checks; no posture scanner, Cisco deployment or system trust changes.'},null,2))The certificate passes and /report returns 200; /admin returns 403 for reader. A new certificate with the same CN needs authorized binding.
Common pitfalls
Unknown as malware; lease as a new scan; certificate as posture; equal CN as authorization; registry removal as PKI revocation.
Related topics: EAP-TLS and certificates · Posture and remediation · Network authorization
Secure access requires trust, identity binding, posture evidence and effective policy on the path used.
Reference: ISE 3.4 Compliance · 350-701 SCOR v2.0, effective 2026-08-27; core component of CCNP Security