AID-E-001.002: Issued Token, Authentication Session & Lease Revocation
Pillars: infra
Lifecycle: response
Scope boundary
Owns revocation or invalidation of the complete incident-scoped population of already-issued access and refresh tokens, session cookies, authorization-server sessions, gateway authentication sessions, workload certificates, established authenticated channels, and agent-to-agent leases. Automated response integration is only an implementation method, not a separate control outcome.
AID-E-001.001: AID-E-001.002 revokes already-issued authentication artifacts; AID-E-001.001 evicts their root or long-lived credential objects. Revoking current tokens and sessions does not rotate a compromised password, key, client secret, signing key, or long-lived certificate that can issue future artifacts.
AID-E-001.003: AID-E-001.002 invalidates artifacts already issued to a principal; AID-E-001.003 disables the principal and stops new issuance. Existing tokens can remain effective after principal disablement, and revoking the current token set does not necessarily prevent the principal from obtaining replacements.
AID-E-001.004: AID-E-001.002 revokes issued tokens and authentication sessions; AID-E-001.004 removes the delegated grants or connected-app authorizations that can mint fresh tokens or sustain SaaS access. Both populations require authoritative verification when a delegated authorization was involved.
AID-E-005: AID-E-001.002 revokes identity-provider and gateway authentication sessions; AID-E-005 removes durable application session and agent-state records. A durable conversation, webhook, queue, schedule, or tool registration can survive authentication-session revocation and reload compromised state later.
Implementation guidance
- AID-E-001.002-G001: Revoke the complete incident-scoped population of access tokens, refresh tokens, cookies, authorization-server sessions, gateway sessions, and agent leases at their authoritative issuers and enforcement points.
- AID-E-001.002-G002: Ensure prompt propagation of revocation for stateless tokens.
- AID-E-001.002-G003: Invalidate already-issued workload certificates and terminate established mTLS sessions at every relying-party boundary.
AID-E-001.002-G001
Signed target population
This example owns already-issued Keycloak online user sessions. It does not disable AWS access keys or other long-lived credentials; those belong to AID-E-001.001. It does not delete delegated grants, consents, or app-role assignments that can authorize future issuance; those belong to AID-E-001.004. The incident scope must declare every applicable issuer and artifact class. Offline sessions, broker sessions, provider-native refresh families, gateway sessions, and agent leases require their own authoritative adapters; an applicable class without a working enumerator and revoker leaves E-001.002 incomplete.
The recovery authority signs the exact immutable admin API base URL, issuer, realm, subject, session, incident, and policy bindings. Never place raw access or refresh tokens in the manifest or ordinary logs.
{
"schema_version": "aidefend.e001002.keycloak-session-eviction/v1",
"control": "AID-E-001.002",
"admin_base_url": "https://id.example.com",
"issuer": "https://id.example.com/realms/ai-production",
"realm": "ai-production",
"subject_id": "6a105f68-26d7-45f2-9567-7b3ee5bff4a7",
"session_ids": [
"8f715b78-8075-4e98-8690-1ed91f7c90ef"
],
"incident_id": "INC-2026-0718-0042",
"policy_sha256": "6d6f9cb8164f379df6e0d4dc97e7c8797806f7657fd44a4c44a9ada0c1c6c8b3"
}Revoke only the exact signed Keycloak session population
# File: incident_response/revoke_keycloak_sessions.py
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
import subprocess
import tempfile
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote, urlsplit
import ssl
import httpx
EXPECTED_FIELDS = {
"schema_version", "control", "admin_base_url", "issuer", "realm",
"subject_id", "session_ids", "incident_id", "policy_sha256",
}
SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$")
TRUST_KEY = Path("/opt/aidefend/trust/e001002-session-authority.pub")
OWNER_TOKEN_FILE = Path(os.environ["KEYCLOAK_OWNER_TOKEN_FILE"])
CA_FILE = os.environ["KEYCLOAK_CA_FILE"]
TLS_CONTEXT = ssl.create_default_context(cafile=CA_FILE)
RUNTIME_PROFILE_VERSION = os.environ["AIDEFEND_RUNTIME_PROFILE_VERSION"].strip()
RUNTIME_PROFILE_SHA256 = os.environ["AIDEFEND_RUNTIME_PROFILE_SHA256"].strip()
COMMAND_TIMEOUT_SECONDS = float(os.environ["E001002_COMMAND_TIMEOUT_SECONDS"])
CONNECT_TIMEOUT_SECONDS = float(os.environ["E001002_CONNECT_TIMEOUT_SECONDS"])
READ_TIMEOUT_SECONDS = float(os.environ["E001002_READ_TIMEOUT_SECONDS"])
MAXIMUM_RESPONSE_BYTES = int(os.environ["E001002_MAXIMUM_RESPONSE_BYTES"])
if (
not RUNTIME_PROFILE_VERSION
or len(RUNTIME_PROFILE_SHA256) != 64
or set(RUNTIME_PROFILE_SHA256) - set("0123456789abcdef")
or RUNTIME_PROFILE_SHA256 == "0" * 64
or any(
not math.isfinite(value) or value <= 0
for value in (
COMMAND_TIMEOUT_SECONDS,
CONNECT_TIMEOUT_SECONDS,
READ_TIMEOUT_SECONDS,
)
)
or MAXIMUM_RESPONSE_BYTES < 1
):
raise RuntimeError("versioned E-001.002 runtime profile is invalid")
def reject_duplicate_keys(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate signed JSON key: {key}")
value[key] = item
return value
def reject_nonfinite(value):
raise ValueError(f"non-finite signed JSON value: {value}")
def strict_json(raw: bytes, label: str):
try:
return json.loads(
raw.decode("utf-8", errors="strict"),
object_pairs_hook=reject_duplicate_keys,
parse_constant=reject_nonfinite,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f"{label} is not strict UTF-8 JSON") from exc
def verified_signed_bytes(
payload_path: Path,
bundle_path: Path,
trust_key: str | Path,
timeout_seconds: float,
label: str,
) -> bytes:
payload = payload_path.read_bytes()
bundle = bundle_path.read_bytes()
if not payload or not bundle:
raise ValueError(f"{label} payload or bundle is empty")
with tempfile.TemporaryDirectory(prefix="aidefend-signed-input-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
payload_snapshot = root / "payload"
bundle_snapshot = root / "payload.sigstore.json"
payload_snapshot.write_bytes(payload)
bundle_snapshot.write_bytes(bundle)
os.chmod(payload_snapshot, 0o400)
os.chmod(bundle_snapshot, 0o400)
subprocess.run(
["cosign", "verify-blob", "--key", str(trust_key),
"--bundle", str(bundle_snapshot), str(payload_snapshot)],
check=True, capture_output=True, text=True, timeout=timeout_seconds,
)
verified = payload_snapshot.read_bytes()
verified_bundle = bundle_snapshot.read_bytes()
if verified != payload or verified_bundle != bundle:
raise OSError(f"{label} verified payload or bundle snapshot changed")
return verified
def canonical_json_bytes(value: dict) -> bytes:
return (
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
)
+ "\n"
).encode("utf-8")
def require_sha256(value: object, field: str) -> str:
if not isinstance(value, str) or len(value) != 64:
raise ValueError(f"{field} must be a SHA-256 digest")
try:
int(value, 16)
except ValueError as exc:
raise ValueError(f"{field} must be a SHA-256 digest") from exc
if value.lower() == "0" * 64:
raise ValueError(f"{field} must not be the unresolved zero digest")
return value.lower()
def verify_manifest(path: Path, bundle: Path) -> tuple[dict, bytes]:
raw = verified_signed_bytes(
path, bundle, TRUST_KEY, COMMAND_TIMEOUT_SECONDS,
"Keycloak session-eviction manifest",
)
value = strict_json(raw, "Keycloak session-eviction manifest")
if (
not isinstance(value, dict)
or set(value) != EXPECTED_FIELDS
or raw != canonical_json_bytes(value)
or value["schema_version"]
!= "aidefend.e001002.keycloak-session-eviction/v1"
or value["control"] != "AID-E-001.002"
):
raise ValueError("manifest schema, canonical bytes, or control differs")
for field in (
"admin_base_url", "issuer", "realm", "subject_id", "incident_id",
):
if not isinstance(value[field], str) or not value[field]:
raise ValueError(f"manifest.{field} is required")
if (
not SAFE_SEGMENT.fullmatch(value["realm"])
or not SAFE_SEGMENT.fullmatch(value["subject_id"])
):
raise ValueError("realm or subject ID is not a bounded path segment")
sessions = value["session_ids"]
if (
not isinstance(sessions, list)
or not sessions
or sessions != sorted(set(sessions))
or any(
not isinstance(item, str) or not SAFE_SEGMENT.fullmatch(item)
for item in sessions
)
):
raise ValueError("session_ids must be a sorted unique exact-ID list")
value["policy_sha256"] = require_sha256(
value["policy_sha256"], "manifest.policy_sha256"
)
return value, raw
def read_owner_token() -> str:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(OWNER_TOKEN_FILE, flags)
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise PermissionError("owner token must be a regular file")
if stat.S_IMODE(info.st_mode) & 0o077:
raise PermissionError(
"owner token file must not be group/world accessible"
)
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
descriptor = -1
token = handle.read().strip()
finally:
if descriptor != -1:
os.close(descriptor)
if not token or "\n" in token or "\r" in token:
raise PermissionError("owner token file is empty or malformed")
return token
def bounded_json(client: httpx.Client, path: str) -> object:
with client.stream("GET", path) as response:
response.raise_for_status()
chunks = []
size = 0
for chunk in response.iter_bytes():
size += len(chunk)
if size > MAXIMUM_RESPONSE_BYTES:
raise RuntimeError("Keycloak response exceeds signed bound")
chunks.append(chunk)
return strict_json(b"".join(chunks), "Keycloak response")
def enumerate_session_ids(
client: httpx.Client,
realm: str,
subject_id: str,
) -> list[str]:
path = (
f"/admin/realms/{quote(realm, safe='')}/users/"
f"{quote(subject_id, safe='')}/sessions"
)
value = bounded_json(client, path)
if not isinstance(value, list):
raise RuntimeError("Keycloak session response must be an array")
identifiers = []
for item in value:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
raise RuntimeError("Keycloak session response contains an invalid row")
identifiers.append(item["id"])
if len(identifiers) != len(set(identifiers)):
raise RuntimeError("Keycloak returned duplicate session IDs")
return sorted(identifiers)
def main() -> None:
manifest, manifest_bytes = verify_manifest(
Path(sys.argv[1]), Path(sys.argv[2])
)
admin_base_url = manifest["admin_base_url"].rstrip("/")
issuer = manifest["issuer"].rstrip("/")
admin_parts = urlsplit(admin_base_url)
issuer_parts = urlsplit(issuer)
for label, parts in (
("admin_base_url", admin_parts),
("issuer", issuer_parts),
):
if (
parts.scheme != "https"
or not parts.netloc
or parts.username is not None
or parts.password is not None
or parts.query
or parts.fragment
):
raise ValueError(f"signed {label} must be an absolute HTTPS URL")
if admin_parts.path not in ("", "/"):
raise ValueError("signed admin_base_url must be an HTTPS origin")
if (
admin_parts.scheme,
admin_parts.hostname,
admin_parts.port,
) != (
issuer_parts.scheme,
issuer_parts.hostname,
issuer_parts.port,
):
raise ValueError("Keycloak admin base and issuer origins differ")
expected_issuer = (
f"{admin_base_url}/realms/{quote(manifest['realm'], safe='')}"
)
if issuer != expected_issuer:
raise ValueError("signed issuer is not the declared Keycloak realm issuer")
discovery_url = issuer + "/.well-known/openid-configuration"
timeout = httpx.Timeout(
READ_TIMEOUT_SECONDS,
connect=CONNECT_TIMEOUT_SECONDS,
write=READ_TIMEOUT_SECONDS,
pool=CONNECT_TIMEOUT_SECONDS,
)
with httpx.Client(
verify=TLS_CONTEXT,
timeout=timeout,
follow_redirects=False,
trust_env=False,
) as discovery_client:
discovery = bounded_json(discovery_client, discovery_url)
if (
not isinstance(discovery, dict)
or not isinstance(discovery.get("issuer"), str)
or discovery["issuer"].rstrip("/") != issuer
):
raise RuntimeError("OIDC discovery issuer differs from signed issuer")
headers = {"Authorization": f"Bearer {read_owner_token()}"}
targets = manifest["session_ids"]
actions = []
with httpx.Client(
base_url=admin_base_url,
headers=headers,
verify=TLS_CONTEXT,
timeout=timeout,
follow_redirects=False,
trust_env=False,
) as client:
before = enumerate_session_ids(
client, manifest["realm"], manifest["subject_id"]
)
before_set = set(before)
for session_id in targets:
if session_id not in before_set:
actions.append(
{
"session_id": session_id,
"present_before": False,
"action": "already_absent",
"delete_status": None,
}
)
continue
path = (
f"/admin/realms/{quote(manifest['realm'], safe='')}/sessions/"
f"{quote(session_id, safe='')}"
)
with client.stream("DELETE", path) as response:
delete_status = response.status_code
response_size = 0
for chunk in response.iter_bytes():
response_size += len(chunk)
if response_size > MAXIMUM_RESPONSE_BYTES:
raise RuntimeError(
"Keycloak deletion response exceeds signed bound"
)
if delete_status not in (204, 404):
raise RuntimeError(
f"Keycloak session deletion failed: {delete_status}"
)
actions.append(
{
"session_id": session_id,
"present_before": True,
"action": "delete",
"delete_status": delete_status,
}
)
after = enumerate_session_ids(
client, manifest["realm"], manifest["subject_id"]
)
remaining = sorted(set(targets) & set(after))
if remaining:
raise RuntimeError(
"target sessions remain after revocation: " + ",".join(remaining)
)
receipt = {
"schema_version": "aidefend.e001002.session-owner-receipt/v1",
"control": "AID-E-001.002",
"admin_base_url": admin_base_url,
"issuer": issuer,
"realm": manifest["realm"],
"subject_id": manifest["subject_id"],
"incident_id": manifest["incident_id"],
"policy_sha256": manifest["policy_sha256"],
"runtime_profile_version": RUNTIME_PROFILE_VERSION,
"runtime_profile_sha256": RUNTIME_PROFILE_SHA256,
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
"target_session_ids": targets,
"actions": actions,
"remaining_target_session_ids": remaining,
"completed_at": datetime.now(timezone.utc).isoformat(),
}
sys.stdout.buffer.write(canonical_json_bytes(receipt))
if __name__ == "__main__":
main()
Run and sign the owner receipt
#!/usr/bin/env bash
set -euo pipefail
python incident_response/revoke_keycloak_sessions.py evidence/e001002-keycloak-sessions.json evidence/e001002-keycloak-sessions.json.sigstore.json > evidence/e001002-session-owner-receipt.json
cosign sign-blob --yes --key env://E001002_OWNER_SIGNING_KEY --bundle evidence/e001002-session-owner-receipt.json.sigstore.json evidence/e001002-session-owner-receipt.jsonIndependent verification
A separate read-only Keycloak identity re-verifies the authority manifest and owner receipt, enumerates the same subject and exact target population, and requires every targeted session ID to be absent. It also checks every declared provider/session-class adapter, confirms non-target sessions were not revoked, and performs a provider-approved negative access or refresh test for each safely retained incident token. For a self-contained JWT that a relying party could still accept after issuer-session deletion, require the deny-state propagation and relying-party probes in the next guidance before E-001.002 passes. Preserve canonical manifests and receipts, signatures, IdP readbacks, negative probes, adapter versions, policy digests, and errors in immutable incident evidence.
Action: revoke only the exact signed issued-session population at its authoritative issuer, then accept E-001.002 only after independent absence and end-to-end denial evidence covers every applicable token and session class.
AID-E-001.002-G002
Runtime policy: Load permitted clock skew, Redis connect/socket/health-check bounds, and JWKS cache lifetime from the signed, versioned JWT-revocation profile bound to this issuer, audience, gateway release, and namespace, bind its version/digest to enforcement evidence, and return ERROR if the profile or any value is absent, invalid, or unverifiable.
Concept:
A stateless token is not accepted merely because it parses. The enforcement point must verify the signature with an issuer-controlled key, pin the allowed algorithm, require the expected issuer and audience, enforce exp/iat/nbf time claims, require a non-empty jti, and then consult a shared revocation store on every request. A missing key, unavailable JWKS endpoint, unavailable revocation store, malformed claim, or stale token must fail closed.
Step 1: Verify the token and create a scoped revocation record
# File: api/jwt_revocation.py
from __future__ import annotations
import json
import math
import os
import ssl
import time
from urllib.parse import urlsplit
import httpx
import jwt
import redis
from jwt import InvalidTokenError
ISSUER = os.environ["JWT_ISSUER"].rstrip("/")
AUDIENCE = os.environ["JWT_AUDIENCE"]
JWKS_URL = os.environ["JWT_JWKS_URL"]
REVOCATION_REDIS_URL = os.environ["REVOCATION_REDIS_URL"]
ALLOWED_ALGORITHMS = ["RS256"]
RUNTIME_PROFILE_VERSION = os.environ["AIDEFEND_RUNTIME_PROFILE_VERSION"].strip()
RUNTIME_PROFILE_SHA256 = os.environ["AIDEFEND_RUNTIME_PROFILE_SHA256"].strip()
CLOCK_SKEW_SECONDS = float(os.environ["JWT_CLOCK_SKEW_SECONDS"])
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.environ["JWT_REDIS_CONNECT_TIMEOUT_SECONDS"])
REDIS_SOCKET_TIMEOUT_SECONDS = float(os.environ["JWT_REDIS_SOCKET_TIMEOUT_SECONDS"])
REDIS_HEALTH_CHECK_INTERVAL_SECONDS = float(os.environ["JWT_REDIS_HEALTH_CHECK_INTERVAL_SECONDS"])
JWKS_CACHE_LIFESPAN_SECONDS = int(os.environ["JWT_JWKS_CACHE_LIFESPAN_SECONDS"])
JWKS_TIMEOUT_SECONDS = float(os.environ["JWT_JWKS_TIMEOUT_SECONDS"])
JWKS_CONNECT_TIMEOUT_SECONDS = float(os.environ["JWT_JWKS_CONNECT_TIMEOUT_SECONDS"])
JWKS_MAX_RESPONSE_BYTES = int(os.environ["JWT_JWKS_MAX_RESPONSE_BYTES"])
JWKS_RESPONSE_CHUNK_BYTES = int(os.environ["JWT_JWKS_RESPONSE_CHUNK_BYTES"])
JWKS_CA_FILE = os.environ["JWT_JWKS_CA_FILE"]
if (not RUNTIME_PROFILE_VERSION or len(RUNTIME_PROFILE_SHA256) != 64
or set(RUNTIME_PROFILE_SHA256) - set("0123456789abcdef")
or RUNTIME_PROFILE_SHA256 == "0" * 64
or not math.isfinite(CLOCK_SKEW_SECONDS) or CLOCK_SKEW_SECONDS < 0
or any(not math.isfinite(value) or value <= 0 for value in (
REDIS_CONNECT_TIMEOUT_SECONDS, REDIS_SOCKET_TIMEOUT_SECONDS,
REDIS_HEALTH_CHECK_INTERVAL_SECONDS, JWKS_TIMEOUT_SECONDS,
JWKS_CONNECT_TIMEOUT_SECONDS,
))
or JWKS_CACHE_LIFESPAN_SECONDS < 1
or JWKS_MAX_RESPONSE_BYTES < 2
or JWKS_RESPONSE_CHUNK_BYTES < 1
or JWKS_RESPONSE_CHUNK_BYTES > JWKS_MAX_RESPONSE_BYTES):
raise RuntimeError("versioned JWT-revocation runtime profile is invalid")
redis_client = redis.Redis.from_url(
REVOCATION_REDIS_URL,
decode_responses=True,
socket_connect_timeout=REDIS_CONNECT_TIMEOUT_SECONDS,
socket_timeout=REDIS_SOCKET_TIMEOUT_SECONDS,
health_check_interval=REDIS_HEALTH_CHECK_INTERVAL_SECONDS,
)
_jwks_document = None
_jwks_expires_at = 0.0
def reject_duplicate_keys(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate JWKS key: {key}")
value[key] = item
return value
def https_origin(value: str) -> tuple[str, str, int]:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ValueError("issuer/JWKS URL is not canonical HTTPS")
return parsed.scheme, parsed.hostname.lower(), parsed.port or 443
def load_jwks() -> dict:
global _jwks_document, _jwks_expires_at
now = time.monotonic()
if _jwks_document is not None and now < _jwks_expires_at:
return _jwks_document
if https_origin(JWKS_URL) != https_origin(ISSUER):
raise ValueError("JWKS origin differs from the signed issuer origin")
tls = ssl.create_default_context(cafile=JWKS_CA_FILE)
timeout = httpx.Timeout(JWKS_TIMEOUT_SECONDS, connect=JWKS_CONNECT_TIMEOUT_SECONDS)
with httpx.Client(verify=tls, timeout=timeout, follow_redirects=False) as client:
with client.stream("GET", JWKS_URL, headers={"Accept-Encoding": "identity"}) as response:
if 300 <= response.status_code < 400:
raise RuntimeError("JWKS redirect is forbidden")
response.raise_for_status()
chunks, observed = [], 0
for chunk in response.iter_bytes(JWKS_RESPONSE_CHUNK_BYTES):
observed += len(chunk)
if observed > JWKS_MAX_RESPONSE_BYTES:
raise RuntimeError("JWKS response exceeds signed bound")
chunks.append(chunk)
document = json.loads(b"".join(chunks).decode("utf-8", errors="strict"), object_pairs_hook=reject_duplicate_keys, parse_constant=lambda value: (_ for _ in ()).throw(ValueError('non-finite JSON constant: ' + value)))
if not isinstance(document, dict) or set(document) != {"keys"} or not isinstance(document["keys"], list):
raise ValueError("JWKS schema differs")
_jwks_document = document
_jwks_expires_at = now + JWKS_CACHE_LIFESPAN_SECONDS
return document
def verify_token(token: str) -> dict:
if not isinstance(token, str) or not token:
raise PermissionError("bearer token is missing")
try:
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str) or not header["kid"]:
raise ValueError("JWT header is invalid")
matches = [item for item in load_jwks()["keys"]
if isinstance(item, dict) and item.get("kid") == header["kid"] and item.get("alg") == "RS256"]
if len(matches) != 1:
raise ValueError("JWT signing key is absent or ambiguous")
signing_key = jwt.PyJWK.from_dict(matches[0], algorithm="RS256").key
claims = jwt.decode(
token, signing_key, algorithms=ALLOWED_ALGORITHMS,
audience=AUDIENCE, issuer=ISSUER, leeway=CLOCK_SKEW_SECONDS,
options={"require": ["sub", "jti", "iat", "nbf", "exp"],
"verify_signature": True, "verify_aud": True, "verify_iss": True,
"verify_iat": True, "verify_nbf": True, "verify_exp": True},
)
except (InvalidTokenError, httpx.HTTPError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise PermissionError("JWT cryptographic or claim validation failed") from exc
jti = claims.get("jti")
exp = claims.get("exp")
if not isinstance(jti, str) or not jti or isinstance(exp, bool) or not isinstance(exp, int):
raise PermissionError("JWT jti or exp claim is invalid")
return claims
def revocation_key(jti: str) -> str:
return f"jwt_revoked:{ISSUER}:{AUDIENCE}:{jti}"
def revoke_verified_token(token: str, incident_id: str) -> dict:
claims = verify_token(token)
jti = claims["jti"]
exp = claims["exp"]
# Retain through the verifier's complete accepted lifetime. Ceil avoids
# truncating a fractional skew; one-second retention margin is arithmetic
# precision, not a universal operational policy.
deny_until = math.ceil(exp + CLOCK_SKEW_SECONDS)
ttl = max(1, math.ceil(deny_until - time.time()) + 1)
if ttl <= 0:
raise PermissionError("cannot create a revocation record for an expired token")
evidence = json.dumps(
{"incident_id": incident_id, "sub": claims["sub"], "jti": jti, "exp": exp},
sort_keys=True,
)
key = revocation_key(jti)
try:
stored = redis_client.set(key, evidence, ex=ttl)
observed = redis_client.get(key)
except redis.RedisError as exc:
raise RuntimeError("revocation store unavailable; token remains denied") from exc
if stored is not True or observed != evidence:
raise RuntimeError("revocation record write/readback differs")
return {
"jti": jti,
"expires_at": exp,
"deny_until": deny_until,
"revoked": True,
"runtime_profile_version": RUNTIME_PROFILE_VERSION,
"runtime_profile_sha256": RUNTIME_PROFILE_SHA256,
}Step 2: Enforce the deny decision on every authenticated request
# File: api/auth_middleware.py
import redis
from fastapi import HTTPException
from api.jwt_revocation import redis_client, revocation_key, verify_token
def validate_token_with_revocation_check(token: str) -> dict:
try:
claims = verify_token(token)
revoked = redis_client.exists(revocation_key(claims["jti"]))
except PermissionError as exc:
raise HTTPException(status_code=401, detail="invalid bearer token") from exc
except redis.RedisError as exc:
raise HTTPException(status_code=503, detail="token revocation check unavailable") from exc
if revoked:
raise HTTPException(status_code=401, detail="bearer token has been revoked")
return claimsStep 3: Verify propagation independently
From a separately credentialed probe, present the revoked token to every gateway or relying-party class and require an authentication denial. Store the signed probe result with issuer, audience, jti, policy revision, probe timestamp, and token expiry and deny_until in append-only incident evidence. Repeat the denial probe after nominal exp but before exp plus the configured verifier leeway; include fractional-clock boundaries and revocation during that leeway window. Alert if a relying party accepts the token or if the expected denial record is absent.
Action: Cryptographically validate before deriving jti/exp, write a TTL-bound scoped deny record to the shared store, fail closed when verification or deny-state lookup is unavailable, and close the response only after a fresh independent negative test proves the revoked token is denied at every in-scope relying party.
AID-E-001.002-G003
Runtime policy: Load replica convergence deadline, poll cadence, probe timeout, and receipt age from the signed, versioned rollout policy bound to this broker release, bundle revision, and replica population, bind its version/digest to observations and probe replay, and return ERROR if the policy or any value is absent, invalid, or unverifiable.
Concept and owner boundary
This method owns already-issued workload certificates or SVIDs and the established mTLS sessions that can continue using them. Disabling a SPIRE registration, cloud workload binding, agent principal, or token-exchange subject stops new issuance under AID-E-001.003 but does not invalidate these artifacts. Rotating an issuer signing key or trust anchor belongs to AID-E-001.001 and is required only when that long-lived issuer credential is itself affected. Expiry is a bounded residual-risk deadline, not proof of active revocation.
Step 1: Revoke the exact certificate in a PKI that supports CRL or OCSP
The following commands are appropriate only for an OpenSSL-backed internal CA whose database is serialized and protected by an HSM-backed operator workflow. For an enterprise CA, invoke its supported revocation API and export the equivalent signed CRL or OCSP evidence.
#!/usr/bin/env bash
set -euo pipefail
if [ -z "$E001_ROLLOUT_TIMEOUT" ] || [ -z "$E001_TLS_PROBE_TIMEOUT_SECONDS" ]; then
echo "required versioned runtime-profile timeout is missing" >&2; exit 2
fi
case "$E001_TLS_PROBE_TIMEOUT_SECONDS" in
*[!0-9.]*|'') echo "TLS probe timeout must be a required positive numeric profile value" >&2; exit 2 ;;
esac
CA_CONFIG="/etc/pki/aidefend-openssl.cnf"
COMPROMISED_CERT="/incident/INC-2026-0417/compromised-svid.pem"
CA_CHAIN="/etc/pki/aidefend-ca-chain.pem"
CRL_STAGING="/srv/pki/crl/aidefend-current.crl.pem.new"
CRL_ACTIVE="/srv/pki/crl/aidefend-current.crl.pem"
# Serialize access to the OpenSSL CA database in the protected CA job.
flock /run/lock/aidefend-ca.lock \
openssl ca -batch -config "$CA_CONFIG" \
-revoke "$COMPROMISED_CERT" -crl_reason keyCompromise
flock /run/lock/aidefend-ca.lock \
openssl ca -batch -config "$CA_CONFIG" -gencrl -out "$CRL_STAGING"
openssl crl -in "$CRL_STAGING" -noout -verify -CAfile "$CA_CHAIN"
install -o root -g root -m 0444 "$CRL_STAGING" "$CRL_ACTIVE"
sha256sum "$COMPROMISED_CERT" "$CRL_ACTIVE"If the CA publishes OCSP, query the signed responder after revocation and configure each relying party to reject revoked or unverifiable status according to incident policy:
openssl ocsp \
-issuer /etc/pki/aidefend-issuer.pem \
-cert /incident/INC-2026-0417/compromised-svid.pem \
-url https://ocsp.pki.example.org \
-CAfile /etc/pki/aidefend-ca-chain.pem \
-resp_textStep 2: Make relying parties enforce the revocation state
Publish the signed deny bundle at every authorization and federation boundary. For Envoy-based relying parties, deliver the trusted CA and complete PEM CRL set through the protected SDS path. Envoy requires a CRL for each relevant CA in the chain unless the reviewed policy intentionally enables leaf-only checking.
common_tls_context:
validation_context:
trusted_ca:
filename: /etc/envoy/tls/aidefend-ca-chain.pem
crl:
filename: /etc/envoy/tls/aidefend-current.crl.pem
only_verify_leaf_cert_crl: falseApply a principal-specific service-mesh deny before draining sessions:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: deny-compromised-agent
namespace: ai-production
spec:
action: DENY
rules:
- from:
- source:
principals:
- spiffe://example.org/agent/compromised-agentkubectl apply -f deny-compromised-agent.yaml
# Reload trust/CRL material and terminate pooled mTLS sessions on the exact
# relying deployments identified by the incident connection inventory.
kubectl rollout restart deployment/payments-api deployment/model-gateway \
-n ai-production
kubectl rollout status deployment/payments-api -n ai-production --timeout="$E001_ROLLOUT_TIMEOUT"
kubectl rollout status deployment/model-gateway -n ai-production --timeout="$E001_ROLLOUT_TIMEOUT"Step 3: Prove issued-artifact denial and clean session establishment
From every reachable relying-party and federation boundary, present each signed affected certificate or credential and require a fresh handshake to fail. Enumerate the incident-defined preexisting connection population and require every affected connection ID to disappear after the authorized drain or restart. Then prove a separately attested clean certificate establishes only the intended least-privilege session. Archive exact certificate serials, CRL or OCSP responses where supported, relying-party deny or trust-state revisions, rollout receipts, and connection-drain observations.
set -euo pipefail
TARGET="payments-api.ai-production.svc.cluster.local:8443"
if timeout "$E001_TLS_PROBE_TIMEOUT_SECONDS" openssl s_client -brief -verify_return_error \
-connect "$TARGET" \
-cert /incident/INC-2026-0417/compromised-svid.pem \
-key /incident/INC-2026-0417/compromised-svid-key.pem \
-CAfile /etc/pki/aidefend-ca-chain.pem </dev/null; then
echo "old credential was still accepted" >&2
exit 1
fi
timeout "$E001_TLS_PROBE_TIMEOUT_SECONDS" openssl s_client -brief -verify_return_error \
-connect "$TARGET" \
-cert /run/clean-identity/replacement-svid.pem \
-key /run/clean-identity/replacement-svid-key.pem \
-CAfile /etc/pki/aidefend-ca-chain.pem </dev/nullAction: Accept this method only when the complete issued-certificate population is denied at every declared relying party, every affected established session is gone, and the clean replacement session succeeds. Missing serials, unenforced CRL or OCSP state, incomplete relying-party rollout, unknown connection state, or an old credential accepted anywhere blocks AID-E-001.002; it does not become a principal-disablement result.
Open-source tools
- Shuffle SOAR (AGPL-3.0)
- TheHive Project Cortex (AGPL-3.0)
- Valkey
- Redis Open Source 8+ (AGPL-3.0 option)
- Memcached
- Keycloak (token and session revocation)
- HTTPX
- Cosign
- Kong Gateway OSS (Apache-2.0)
- Tyk Gateway Community Edition (MPL-2.0)
- Istio (mTLS relying-party denial and connection turnover)
- Linkerd (mTLS relying-party denial and connection turnover)
- OpenSSL (certificate revocation and mTLS denial verification)
Source-available / open-weight tools
- HashiCorp Vault Community (BUSL-1.1; source-available)
Commercial / hosted tools
- StrangeBee TheHive
- AWS Lambda
- Azure Functions
- Google Cloud Run functions
- Palo Alto Networks Cortex XSOAR
- Splunk SOAR
- Torq Hyperautomation
- Amazon EventBridge
- Azure Event Grid
- Okta Workforce Identity (session revocation)
- Auth0 (session revocation)
- PingOne for Workforce (session revocation)
- Tetrate Service Bridge (mesh identity denial and connection enforcement)
- Gloo Mesh (mesh identity denial and connection enforcement)
Threat mappings
MITRE ATLAS
- AML.T0012 Valid Accounts (revoking the affected issuer sessions and access material stops continued use)
- AML.T0090 OS Credential Dumping (revoking dumped access tokens and session material stops their continued use)
- AML.T0091 Use Alternate Authentication Material (immediately revoking stolen access and refresh tokens)
- AML.T0091.000 Use Alternate Authentication Material: Application Access Token (real-time JWT/API token revocation)
- AML.T0091.001 Use Alternate Authentication Material: Web Session Cookie (real-time session invalidation terminates stolen-cookie access)
MAESTRO
- Privilege Escalation (Cross-Layer)
- Lateral Movement (Cross-Layer)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure (token and session revocation stops continued access during an active disclosure incident)
OWASP ML Top 10 2023
- ML05:2023 Model Theft (by terminating the session used for theft)
OWASP Top 10 for Agentic Applications 2026
- ASI03:2026 Identity and Privilege Abuse
- ASI02:2026 Tool Misuse and Exploitation (denying compromised agent access tokens and sessions)
NIST Adversarial Machine Learning 2025
- NISTAML.031 Model Extraction (terminating active extraction)
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-14.1.2 Insufficient Access Controls (Removes the identified compromised access mechanism; broader identity recovery is separate)
- AISubtech-4.3.4 Replay Exploitation
- AISubtech-7.4.1 Token Theft
- AITech-14.1 Unauthorized Access (revokes issued credentials and established channels at AI hosting-service boundaries)
- AITech-7.4 Token Manipulation
- AITech-8.2 Data Exfiltration / Exposure (stops continued disclosure through the specifically revoked authenticated paths)
- AISubtech-14.1.1 Credential Theft (revokes the identified stolen credential or issued-token population at AI access boundaries)
Google Secure AI Framework 2.0 - Risks
- MXF: Model Exfiltration (real-time invalidation terminates active model theft sessions)
- SDD: Sensitive Data Disclosure (immediate token revocation stops active data leakage)
Databricks AI Security Framework 3.0
- Platform 12.4: Unauthorized privileged access
- Agents - Tools MCP Server 13.19: Credential and Token Exposure
- Agents - Core 13.3: Privilege Compromise