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

  1. Verify explicitly. Every request authenticates and authorizes. Network location is not a credential.
  2. Least privilege. Narrow scopes, short lifetimes, just-in-time elevation.
  3. 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

Example · http
# 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 checked

When 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.

Example · javascript
// 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.

Example · javascript
// 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.