Zero Trust and Continuous Authorization
Authentication as an ongoing evaluation rather than a one-time gate at the door.
Traditional security had a perimeter: get inside the network and you were trusted. Zero trust discards that. Every request is evaluated on its own merits, regardless of where it came from.
The three principles
- Verify explicitly. Every request authenticates and authorizes. Network location is not a credential.
- Least privilege. Narrow scopes, short lifetimes, just-in-time elevation.
- Assume breach. Design so that a compromised component has a bounded blast radius.
Continuous authorization
The interesting shift is from a single decision at login to ongoing evaluation. A session that began legitimately can become suspicious:
- The device fell out of compliance — disk encryption off, OS unpatched.
- The IP moved continents in ten minutes.
- The user was disabled in the directory.
- The behaviour changed — bulk downloads at 3am from an account that never does.
Continuous authorization re-evaluates and can step up (demand MFA), step down (restrict to read-only), or terminate.
The plumbing: shared signals
The Shared Signals Framework and CAEP (Continuous Access Evaluation Protocol) standardise this. An identity provider pushes events — session-revoked, credential-change, device-compliance-change — to relying parties, which react within seconds instead of waiting for a token to expire.
This is what closes the gap short-lived tokens only narrow: rather than "revocation takes effect within ten minutes", you get a push the moment it happens.
Step-up authentication
Not every action needs the same assurance. Reading a dashboard needs a session; changing payout details needs a fresh second factor. Express this with the acr (authentication context class) and auth_time claims: the resource server states what it requires, and the client re-authenticates if the token does not meet it.
Being honest about it
Zero trust is a direction, not a product. Most teams get most of the value from three things: verify on every hop, keep credentials short-lived and narrow, and be able to revoke in seconds. The device-posture and behavioural-analytics layers come later, if ever.
Example
# Step-up: the API states what assurance it needs, and how to get it
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api",
error="insufficient_user_authentication",
error_description="A recent second factor is required",
acr_values="urn:mace:incommon:iap:silver",
max_age="300"
# The client re-runs authorization asking for that context:
GET /authorize?...&acr_values=urn:mace:incommon:iap:silver&max_age=300
# The new token proves it:
# { "acr": "urn:mace:incommon:iap:silver",
# "amr": ["pwd", "otp"], ← methods actually used
# "auth_time": 1754312400 } ← when, so max_age can be checkedWhen to use it
- A payout-details change requires a second factor completed in the last five minutes, so a hijacked session alone cannot redirect money.
- An identity provider pushes a session-revoked event and every relying party terminates within seconds instead of waiting for token expiry.
- A session that jumps continents mid-flight is downgraded to read-only and the user is prompted to re-authenticate.
More examples
Requiring a recent, strong authentication
Returning both required and current assurance lets the client render 'confirm with your authenticator' instead of a generic error or a logout.
// Per-route assurance requirements, expressed declaratively.
const requireAssurance = ({ acr, maxAgeS }) => (req, res, next) => {
const claims = req.tokenClaims;
const methods = claims.amr ?? [];
const strong = methods.includes('otp') || methods.includes('hwk') ||
methods.includes('mfa');
const authAge = Math.floor(Date.now() / 1000) - Number(claims.auth_time ?? 0);
if ((acr && claims.acr !== acr) || !strong || authAge > maxAgeS) {
// Tell the client exactly what is missing and how to obtain it — otherwise
// it can only guess, and it will guess "log the user out".
res.set('WWW-Authenticate',
`Bearer realm="api", error="insufficient_user_authentication", ` +
`acr_values="${acr}", max_age="${maxAgeS}"`);
return res.status(401).json({
error: 'insufficient_user_authentication',
required: { acr, maxAgeSeconds: maxAgeS },
current: { acr: claims.acr ?? null, authAgeSeconds: authAge },
});
}
next();
};
// Reading is cheap.
app.get('/api/payouts', bearerAuth, listPayouts);
// Changing where money goes is not.
app.put('/api/payouts/bank-account',
bearerAuth,
requireAssurance({ acr: 'urn:acme:mfa', maxAgeS: 300 }),
updateBankAccount,
);
// This is the control that survives a stolen session: the attacker holds a
// valid credential and still cannot pass a five-minute-old second factor.Consuming CAEP / shared signal events
Stepping down to read-only rather than terminating on device non-compliance keeps a legitimate user productive while removing the ability to do damage.
// The IdP pushes a signed Security Event Token (SET) when something changes.
// Reacting in seconds is what closes the gap short token lifetimes only narrow.
app.post('/events/caep',
express.text({ type: 'application/secevent+jwt' }),
async (req, res) => {
let payload;
try {
({ payload } = await jwtVerify(req.body, IDP_JWKS, {
issuer: 'https://idp.example.com',
audience: 'https://dfg.com',
}));
} catch {
return res.status(400).json({ error: 'invalid_set' });
}
for (const [type, event] of Object.entries(payload.events ?? {})) {
const subject = event.subject?.sub ?? payload.sub;
switch (type) {
case 'https://schemas.openid.net/secevent/caep/event-type/session-revoked':
await destroyAllSessionsFor(subject);
await revokeAllRefreshTokensFor(subject);
await db.users.increment(subject, 'tokenVersion'); // kills live JWTs
break;
case 'https://schemas.openid.net/secevent/caep/event-type/credential-change':
// Password or MFA changed at the IdP — treat like our own reset.
await destroyAllSessionsFor(subject);
break;
case 'https://schemas.openid.net/secevent/caep/event-type/device-compliance-change':
if (event.current_status === 'not-compliant') {
await restrictSessionsToReadOnly(subject); // step down, not out
}
break;
case 'https://schemas.openid.net/secevent/caep/event-type/assurance-level-change':
if (event.current_level === 'low') {
await requireStepUpOnNextSensitiveAction(subject);
}
break;
}
}
res.sendStatus(202); // acknowledge fast; process async if it is slow
});
// Without this, 'we disabled them in Okta' means 'they lose access when their
// current token expires' — which is exactly the window an insider needs.Risk-based evaluation, kept simple and explainable
Baselining per user rather than globally is what makes the volume signal usable — a global threshold either misses quiet accounts or constantly flags busy ones.
// Behavioural scoring gets complicated fast. A small, explainable rule set
// beats an opaque model you cannot debug at 2am.
export async function evaluateRisk(req, session) {
const signals = [];
// Impossible travel: two locations too far apart for the elapsed time.
const last = session.lastGeo;
if (last) {
const km = haversine(last, geoFor(req.ip));
const hours = (Date.now() - session.lastSeen) / 3_600_000;
if (km / Math.max(hours, 0.01) > 900) signals.push('impossible_travel');
}
if (session.ua && session.ua !== req.get('user-agent')) signals.push('device_change');
if (await isTorExit(req.ip)) signals.push('anonymising_network');
if (await isNewAsn(session.userId, req.ip)) signals.push('new_network');
// Volume anomaly: 20x the user's own baseline, not a global threshold.
const rate = await requestRateFor(session.userId);
if (rate > (await baselineRateFor(session.userId)) * 20) signals.push('volume_spike');
return signals;
}
export async function continuousAuthorization(req, res, next) {
const signals = await evaluateRisk(req, req.sessionData);
if (!signals.length) return next();
await audit.record({ event: 'risk.detected', userId: req.user.id, signals });
// Graduated response — terminating on every anomaly trains users to distrust
// the product, and support absorbs the cost.
if (signals.includes('impossible_travel') || signals.length >= 3) {
await destroySession(req.sid);
return res.status(401).json({ error: 'reauthentication_required', signals });
}
if (isSensitive(req.path)) {
return res.status(401).json({ error: 'insufficient_user_authentication' });
}
req.degraded = true; // read-only for this request; log it and continue
next();
}
Discussion