Continuous Authorization: CAEP, RISC, and Real-Time Session Revocation
The Token Validity Window Problem
OAuth2 access tokens are bearer credentials. Whoever holds a valid token gets access — no questions asked at the resource server, no re-verification of the subject’s current state. The token’s expiry is the only built-in mechanism for access termination.
Standard access token lifetimes range from 15 minutes to several hours. During that window, any of the following can occur without the resource server knowing:
- The user’s account is disabled by HR after a termination
- A threat actor authenticates from a new country and the IDP flags the session as high-risk
- The user’s device falls out of compliance (MDM unenrolls, antivirus definitions expire)
- An admin revokes the user’s role, but the existing token still carries the old claims
- A credential stuffing attack succeeds; the session is now shared with an attacker
The canonical mitigation is short-lived tokens. If access tokens expire every five minutes, the damage window is bounded. But short expiry creates operational load: more token refresh requests, more IDP traffic, potential latency spikes when the refresh endpoint is slow. More critically, it doesn’t solve the problem — it reduces the window, it doesn’t close it.
CAEP (Continuous Access Evaluation Profile) and the broader SSE (Shared Signals and Events) framework are designed to close the window entirely by pushing revocation signals from the IDP to resource servers in near real-time.
This problem is a direct consequence of the token-based access model described in OAuth2 and OIDC hardening. CAEP is the runtime complement to secure token issuance.
SSE Framework: Transmitters, Receivers, and Streams
The Shared Signals and Events framework (OpenID Foundation draft, building on RFC 8935) defines the infrastructure for pushing security events between parties. The core entities:
Transmitter — the party generating events. Typically the IDP (Okta, Azure AD, a Keycloak instance). The transmitter knows when risk signals change: account status, session risk score, device state.
Receiver — the party consuming events. The application, API gateway, or session management service. The receiver acts on events by invalidating sessions, forcing re-authentication, or restricting access.
Event Stream — a configured, authenticated channel between a specific transmitter and receiver. Streams are scoped: a receiver can declare which subjects (users, sessions) it cares about, and what event types it wants.
The SSE Stream Management API (a REST API exposed by the transmitter) lets receivers:
POST /sse/mgmt/stream # create a stream
GET /sse/mgmt/stream # read stream configuration
PATCH /sse/mgmt/stream # update stream (add subjects, change delivery)
DELETE /sse/mgmt/stream # remove stream
POST /sse/mgmt/stream/status # pause/resume/disable stream
POST /sse/mgmt/subjects:add # add a subject to the stream
POST /sse/mgmt/subjects:remove # remove a subject
POST /sse/mgmt/verify # request a verification event
Event delivery uses one of two methods:
Push (webhook) — transmitter POSTs a JWT (Security Event Token, SET) to the receiver’s endpoint. Lower latency, but requires the receiver to have a public HTTPS endpoint.
Poll — receiver GETs events from the transmitter’s polling endpoint. Works behind firewalls, higher latency.
Security Event Tokens (SETs) are JWTs defined in RFC 8417. They have a standard JWT header and payload, with the events claim carrying a map of event URI to event-specific payload. A single SET can carry multiple events.
CAEP Event Types
CAEP (draft-ietf-secevent-caep) defines events that resource servers need to make real-time access decisions.
session-revoked
The most critical event. The IDP is telling all downstream systems: this session is over, stop accepting its tokens.
{
"iss": "https://idp.example.com",
"jti": "7d6c523e-a4b1-4f23-8e1a-9d2c4e5f6a7b",
"iat": 1746787200,
"aud": "https://api.example.com",
"events": {
"https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
"subject": {
"format": "iss_sub",
"iss": "https://idp.example.com",
"sub": "user-7f3a9c"
},
"initiating_entity": "policy",
"reason_admin": {
"en": "Risk score exceeded threshold"
},
"event_timestamp": 1746787198000
}
}
}
The initiating_entity field indicates whether the revocation was admin-initiated, policy-initiated, or user-initiated. The subject can be identified by iss_sub, email, phone_number, did, or opaque identifiers.
token-claims-change
The user’s claims have changed mid-session. A role was removed, a group membership changed, or an attribute was updated. The receiver should re-evaluate access with the new claims.
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change": {
"subject": { "format": "iss_sub", "iss": "...", "sub": "user-7f3a9c" },
"current_level": "http://schemas.openid.net/claims/groups",
"claims": {
"roles": ["viewer"]
},
"event_timestamp": 1746787100000
}
assurance-level-change
The user’s authentication assurance level dropped. They were authenticated with MFA but the MFA session expired, or they re-authenticated with a weaker factor. If the resource server requires a minimum assurance level, it should reject further requests.
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change": {
"subject": { "format": "iss_sub", ... },
"current_level": "nist-sp800-63a-ial1",
"previous_level": "nist-sp800-63a-ial2",
"change_direction": "decrease",
"event_timestamp": 1746787100000
}
device-compliance-change
The user’s device fell out of compliance. MDM policy violation, jailbreak detected, OS version below minimum, or endpoint protection disabled. Used in device-aware zero trust architectures.
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change": {
"subject": {
"format": "complex",
"user": { "format": "iss_sub", "iss": "...", "sub": "user-7f3a9c" },
"device": { "format": "iss_sub", "iss": "...", "sub": "device-ab12" }
},
"current_status": "not-compliant",
"previous_status": "compliant",
"event_timestamp": 1746787100000
}
RISC Events
RISC (Risk and Incident Sharing and Coordination, RFC 8521) predates CAEP and focuses on account-level risk signals rather than session-level. Both are carried over the SSE framework.
account-purged — the account has been deleted. All sessions and tokens for this subject should be revoked immediately and permanently.
account-disabled — the account is suspended but not deleted. Temporary revocation; the account may be re-enabled.
account-enabled — reversal of account-disabled. Sessions can potentially be restored, though in practice it’s cleaner to force re-authentication.
identifier-changed — the user’s email or username changed. Any cached claims containing the old identifier are now stale.
identifier-recycled — an identifier (email, username) previously used by one account has been assigned to a different account. Critical: tokens issued to the old account holder must not be accepted for the new account.
credential-compromised — the IDP detected that the user’s credentials are compromised. This is typically triggered by breach intelligence feeds (HaveIBeenPwned integration, dark web monitoring). All sessions must be revoked and the user forced through credential reset.
opt-in / opt-out — user changed their data sharing preferences. Relevant for privacy-sensitive applications.
Implementing a CAEP Receiver
A CAEP receiver needs three components: a webhook endpoint to receive SETs, validation logic for the SET JWT, and session invalidation connected to your session store.
SET Validation
Every inbound SET must be validated before acting on it. A SET is a signed JWT from the transmitter. Accepting unvalidated events would allow an attacker to forge revocations or claim changes.
import jwt
import httpx
from functools import lru_cache
from datetime import datetime, timezone
TRANSMITTER_ISSUER = "https://idp.example.com"
RECEIVER_AUDIENCE = "https://api.example.com"
@lru_cache(maxsize=1)
def get_transmitter_jwks():
discovery = httpx.get(
f"{TRANSMITTER_ISSUER}/.well-known/openid-configuration"
).json()
jwks_uri = discovery["jwks_uri"]
return httpx.get(jwks_uri).json()
def validate_set(raw_jwt: str) -> dict:
jwks = get_transmitter_jwks()
jwks_client = jwt.PyJWKClient.__new__(jwt.PyJWKClient)
jwks_client.jwk_set_data = jwks
header = jwt.get_unverified_header(raw_jwt)
if header.get("alg") in ("none", "HS256"):
raise ValueError(f"Weak algorithm rejected: {header['alg']}")
key = jwks_client.get_signing_key_from_jwt(raw_jwt)
claims = jwt.decode(
raw_jwt,
key.key,
algorithms=["RS256", "ES256", "PS256"],
audience=RECEIVER_AUDIENCE,
issuer=TRANSMITTER_ISSUER,
options={"require": ["iss", "iat", "jti", "aud", "events"]},
)
iat = datetime.fromtimestamp(claims["iat"], tz=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - iat).total_seconds()
if age_seconds > 300:
raise ValueError(f"SET too old: {age_seconds}s")
return claims
The JWKS cache should be invalidated on key ID mismatch — the transmitter may rotate keys. A production implementation should cache with a TTL of 3600s and refresh on cache miss for unknown key IDs.
Webhook Endpoint
from fastapi import FastAPI, Request, HTTPException, Header
from typing import Optional
import hashlib, hmac, os
app = FastAPI()
WEBHOOK_SECRET = os.environ["CAEP_WEBHOOK_SECRET"]
def verify_signature(body: bytes, signature: Optional[str]) -> bool:
if not signature:
return False
expected = hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
@app.post("/caep/events")
async def receive_set(
request: Request,
x_hub_signature_256: Optional[str] = Header(None),
):
body = await request.body()
if not verify_signature(body, x_hub_signature_256):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
claims = validate_set(body.decode())
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
for event_uri, event_payload in claims.get("events", {}).items():
await dispatch_event(event_uri, event_payload, claims)
return {"status": "accepted"}
Return HTTP 202 or 200 quickly. Event processing should be asynchronous — push to an internal queue and process out-of-band. A slow receiver causes transmitter retry storms.
Session Invalidation
import redis.asyncio as redis
r = redis.Redis(host="redis", decode_responses=True)
SESSION_REVOKED_URI = (
"https://schemas.openid.net/secevent/caep/event-type/session-revoked"
)
ACCOUNT_DISABLED_URI = (
"https://schemas.openid.net/secevent/risc/event-type/account-disabled"
)
CREDENTIAL_COMPROMISED_URI = (
"https://schemas.openid.net/secevent/risc/event-type/credential-compromised"
)
async def dispatch_event(event_uri: str, payload: dict, set_claims: dict):
subject = payload.get("subject", {})
user_sub = extract_sub(subject)
if event_uri == SESSION_REVOKED_URI:
await revoke_user_sessions(user_sub)
elif event_uri == ACCOUNT_DISABLED_URI:
await revoke_user_sessions(user_sub)
await r.set(f"account:disabled:{user_sub}", "1", ex=86400)
elif event_uri == CREDENTIAL_COMPROMISED_URI:
await revoke_user_sessions(user_sub)
await r.set(f"account:credential-reset-required:{user_sub}", "1")
async def revoke_user_sessions(sub: str):
session_keys = await r.smembers(f"user:sessions:{sub}")
async with r.pipeline() as pipe:
for key in session_keys:
pipe.delete(f"session:{key}")
pipe.delete(f"user:sessions:{sub}")
pipe.set(f"revocation:sub:{sub}", "1", ex=3600)
await pipe.execute()
def extract_sub(subject: dict) -> str:
fmt = subject.get("format")
if fmt == "iss_sub":
return subject["sub"]
if fmt == "email":
return subject["email"]
if fmt == "complex":
return extract_sub(subject.get("user", {}))
raise ValueError(f"Unsupported subject format: {fmt}")
The revocation:sub:{sub} key acts as a blocklist for the token validation path. Your resource server’s token introspection or JWT validation middleware should check this key before accepting a token, even if the token’s exp claim is still valid.
async def is_token_revoked(sub: str, jti: str) -> bool:
revoked_sub = await r.exists(f"revocation:sub:{sub}")
revoked_jti = await r.exists(f"revocation:jti:{jti}")
return bool(revoked_sub or revoked_jti)
Okta CAEP Integration
Okta supports CAEP as a transmitter from the Admin Console and API.
Enable the CAEP transmitter under Security > API > Trusted Origins and the SSE stream configuration under Security > Shared Signals. As of 2025, Okta’s CAEP transmitter is available to Identity Engine tenants.
Create a stream via the Okta SSE Management API:
curl -X POST https://your-org.okta.com/api/v1/sse/stream \
-H "Authorization: SSWS ${OKTA_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"delivery": {
"method": "https://schemas.openid.net/secevent/risc/delivery-method/push",
"endpoint_url": "https://api.example.com/caep/events",
"authorization_header": "Bearer your-receiver-auth-token"
},
"events_requested": [
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
"https://schemas.openid.net/secevent/risc/event-type/account-disabled",
"https://schemas.openid.net/secevent/risc/event-type/credential-compromised"
]
}'
Add subjects (users) to the stream as they authenticate. A pattern that works well: on successful token issuance, call the subjects:add endpoint for that user. On session termination, call subjects:remove. This keeps the stream scoped to active sessions rather than the entire user directory.
Okta emits session-revoked on: admin force sign-out, risk engine high-risk decision, Okta Verify push denial, and policy-based sign-out from the admin console.
Azure AD Continuous Access Evaluation
Microsoft’s CAE implementation is built into the Microsoft identity platform (formerly Azure AD) and has been generally available since 2021. It works slightly differently from the SSE/CAEP draft — Microsoft uses a proprietary protocol with resource providers that have registered as CAE-capable, but the principle is identical.
CAE-capable clients (MSAL 1.26+) include a xms_cc claim in token requests: {"xms_cc": {"values": ["cp1"]}}. This signals that the client can handle CAE challenges.
When a CAE event occurs (user account disabled, password changed, user location changed to outside named location, admin-revoked session), the resource server (SharePoint, Exchange, Teams, and custom APIs registered as CAE resources) rejects the current token with:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="", authorization_uri="https://login.microsoftonline.com/...",
error="insufficient_claims",
claims="eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlcyI6WyJjcDEiXX19fQ=="
The claims parameter is a base64-encoded JSON object specifying what additional claims the client must obtain. The client passes this claims parameter in the next token request, forcing re-evaluation at the IDP.
For custom APIs registered as CAE resources in Azure AD, implement the claims challenge response in your middleware:
import base64, json
from fastapi import Request, Response
CAE_CLAIMS_CHALLENGE = base64.b64encode(json.dumps({
"access_token": {
"acrs": {
"essential": True,
"values": ["cp1"]
}
}
}).encode()).decode()
async def cae_challenge_middleware(request: Request, call_next):
response = await call_next(request)
if response.status_code == 401:
response.headers["WWW-Authenticate"] = (
f'Bearer error="insufficient_claims", '
f'claims="{CAE_CLAIMS_CHALLENGE}"'
)
return response
Azure CAE covers a narrower set of events than full CAEP — specifically the critical path events. For device compliance signals, combine CAE with Conditional Access policies and Microsoft Endpoint Manager compliance states.
Custom Policy Decision Point with CAEP
A custom CAE implementation follows the zero trust architecture principle of continuous verification. The architecture:
- CAEP receiver validates and queues inbound SETs
- Event processor updates a session cache (Redis, Memcached, or a distributed cache)
- Policy Decision Point (PDP) is consulted by resource servers on each request
- PDP evaluates current session state against policy, returning permit/deny
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import time
class Decision(Enum):
PERMIT = "permit"
DENY = "deny"
STEP_UP = "step_up"
@dataclass
class SessionState:
sub: str
session_id: str
issued_at: float
assurance_level: str
device_compliant: bool
revoked: bool = False
credential_reset_required: bool = False
custom_claims: dict = field(default_factory=dict)
class PolicyDecisionPoint:
def __init__(self, redis_client, min_assurance_level: str = "aal2"):
self.r = redis_client
self.min_assurance = min_assurance_level
async def evaluate(
self,
sub: str,
session_id: str,
resource: str,
action: str,
) -> tuple[Decision, Optional[str]]:
state = await self._load_session_state(sub, session_id)
if state is None:
return Decision.DENY, "session_not_found"
if state.revoked:
return Decision.DENY, "session_revoked"
if state.credential_reset_required:
return Decision.DENY, "credential_reset_required"
if not state.device_compliant:
if resource in self._device_sensitive_resources():
return Decision.DENY, "device_not_compliant"
if state.assurance_level < self.min_assurance:
return Decision.STEP_UP, "insufficient_assurance"
if await self.r.exists(f"revocation:sub:{sub}"):
return Decision.DENY, "subject_revoked"
return Decision.PERMIT, None
async def _load_session_state(
self, sub: str, session_id: str
) -> Optional[SessionState]:
data = await self.r.hgetall(f"session:{session_id}")
if not data or data.get("sub") != sub:
return None
return SessionState(
sub=data["sub"],
session_id=session_id,
issued_at=float(data.get("iat", 0)),
assurance_level=data.get("assurance_level", "aal1"),
device_compliant=data.get("device_compliant", "false") == "true",
revoked=data.get("revoked", "false") == "true",
credential_reset_required=data.get("cred_reset", "false") == "true",
)
def _device_sensitive_resources(self) -> set:
return {"/api/admin", "/api/finance", "/api/pii"}
The PDP is called on every request from the identity-aware proxy or API gateway. The Redis lookups must be fast — target sub-millisecond latency with connection pooling and pipelining. If the session cache is unavailable, fail closed (deny) for sensitive resources, fail open for non-sensitive resources, or use a configurable circuit breaker.
Short-Lived Tokens vs CAEP: Tradeoffs
The simpler alternative to CAEP is aggressive token expiry. A 5-minute access token with immediate refresh limits the damage window without any IDP integration. When should you use each approach?
Short-lived tokens work well when:
- Your IDP doesn’t support CAEP transmitter functionality
- You have a simple architecture: one IDP, one or two resource servers
- Token refresh overhead is acceptable (your clients are server-side apps, not mobile apps on flaky networks)
- The threat model is primarily about leaked tokens, not real-time account state changes
CAEP is required when:
- You need immediate revocation on account termination (the 5-minute window is too long)
- Device compliance is a gate for access and devices can fall out of compliance rapidly
- You have regulatory requirements for session management (HIPAA, FedRAMP, PCI DSS 4.0 requirement 8.3.9)
- You operate at scale where millions of short-lived token refreshes create meaningful IDP load
The hybrid approach: Use 15-minute access tokens with CAEP. The short expiry handles the case where your CAEP receiver is temporarily unavailable. CAEP handles immediate revocation for high-severity events. Refresh tokens use CAEP’s session-revoked as a hard gate — even a valid refresh token is rejected if a revocation signal is in the cache.
The deployment complexity of CAEP is real: you need a publicly reachable HTTPS endpoint (or reliable polling), JWT validation infrastructure, a session cache, and integration with your session management layer. Start with the RISC subset — account-disabled and credential-compromised — which have the highest security ROI and are the simplest to handle (both mean: revoke everything, ask questions later).
For CAEP’s more nuanced events (assurance-level-change, token-claims-change), the receiver logic requires policy decisions: what assurance level does this resource require? Which claim changes affect access? These decisions belong in the PDP, not the receiver itself. Keep the receiver simple — validate, queue, acknowledge. Keep the policy logic in the PDP where it can be tested and audited independently.
The SSE specification and CAEP drafts continue to evolve. Okta, Microsoft, Ping, and ForgeRock have committed to interoperability. The OpenID Foundation’s CAEP working group maintains reference implementations. Production deployments today should treat the event URIs as stable but be prepared for stream management API changes as the specification finalizes.