OAuth 2.0's Two Extension Points, and the Introspection API That Bridges Them
⚠️ This post is generated by LLM, read with caution.
OAuth 2.0 is usually learned as a closed box: four grant types, bearer tokens, done. The spec is actually a frame with two deliberately open slots, and most of what has been built on OAuth 2.0 since 2012 has been built by filling one or both of them. The slot on the client side is grant_type — what the client presents to obtain a token. The slot on the resource server side is token_type — how an issued token is meant to be used. RFC 6749 defines both slots and then devotes its extension rules to them: new token types are defined by assigning them a unique absolute URI (§8.1), and new authorization grant types the same way (§8.3). That is the entire extensibility model — not a mechanism, but two registered positions.
The second half of this post is about the most common payload flowing through the second slot: opaque bearer tokens, which a resource server can’t read and has to ask about. The canonical way to ask is RFC 7662 token introspection. I walk through it the way a resource server would actually use it — endpoint security, the full response field set, the validation sequence, iss-based tenant disambiguation, jti-based replay prevention, and caching — with three self-contained Python demos, each a real HTTP server and client talking over loopback.
Two extension points: grant_type and token_type
The first demo runs a miniature authorization server that accepts two grant types — the core client_credentials grant and a made-up urn:example:otp — and a resource-server function that behaves differently depending on the token type it’s handed.
The grant side is the easier concept. RFC 6749 §4.1–4.4 define the four core grant flows (authorization code, implicit, resource owner password, client credentials), and client_credentials in particular is for a client requesting access to resources under its own control — with the constraint that it “MUST only be used by confidential clients” (§4.4). A new grant type is any absolute URI an authorization server chooses to accept, per §8.3, and anything else gets a structured refusal: the unsupported_grant_type error code, defined in §7.2 as “the authorization grant type is not supported by the authorization server.”
The token side is where the model turns out to be load-bearing. Per RFC 6749 §7.1, the token_type value gives “the client with the information required to successfully utilize the access token,” and the client “MUST NOT use an access token if it does not understand the token type.” Read from the resource server’s perspective, that’s an interop contract: the type name is how both sides agree on verification strategy. bearer + opaque means “ask the authorization server” (that’s introspection, the next section). A URI-named type can mean “verify locally with a key you already share” — and §8.1 says URI-named types “SHOULD be limited to vendor-specific implementations,” which is precisely what a custom type in one deployment is.
Two deliberate simplifications in the code: the token endpoint doesn’t authenticate the client at all (a real client_credentials deployment would, per §4.4), and the “signed” token is a toy HMAC stand-in, not a JWT — it exists to show the strategy a locally-verifiable token type implies (verify with a shared key, no round trip), not a production token format.
The full source:
"""Grant types and token types as the two extension points of OAuth 2.0.
Mini AS: token endpoint (2 grant types, one of them a URN extension) +
introspection. Two token types: opaque bearer (needs introspection) and a
custom type the resource server verifies locally (toy HMAC, teaching only)."""
import base64, hashlib, hmac, json, secrets, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib import request as ureq, error as uerr, parse as uparse
PORT = 8101
AUD = "https://api.example.com"
CLIENT_ID, CLIENT_SECRET = "billing", "b-secret"
LOCAL_KEY = b"toy-shared-key" # toy stand-in for real key management
now = int(time.time())
STORE = {}
def issue(token, **rec):
rec.setdefault("exp", now + 3600)
rec.setdefault("scope", "billing:read")
rec.setdefault("aud", AUD)
rec.setdefault("iss", "https://as.example.com")
rec.setdefault("client_id", CLIENT_ID)
rec.setdefault("sub", rec.get("sub") or "client:" + CLIENT_ID)
STORE[token] = rec
return token
def signed_token(**rec):
rec = dict(rec)
rec["exp"] = now + 3600
payload = json.dumps(rec, sort_keys=True).encode()
sig = hmac.new(LOCAL_KEY, payload, hashlib.sha256).hexdigest()
return "sig." + payload.hex() + "." + sig
class AS(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
form = uparse.parse_qs(self.rfile.read(int(self.headers.get("Content-Length", 0))).decode())
if self.path == "/token":
grant = form.get("grant_type", [None])[0]
if grant == "client_credentials":
t = issue("opaque-" + secrets.token_hex(8), sub="client:" + CLIENT_ID)
return self._send(200, {"access_token": t, "token_type": "bearer",
"expires_in": 3600, "scope": "billing:read"})
if grant == "urn:example:otp": # extension grant, URN namespaced
t = issue("opaque-" + secrets.token_hex(8), sub="user:4711")
return self._send(200, {"access_token": t, "token_type": "bearer",
"expires_in": 300, "scope": "billing:read"})
return self._send(400, {"error": "unsupported_grant_type",
"error_description": f"unknown grant type: {grant}"})
if self.path == "/introspect":
rec = STORE.get(form.get("token", [None])[0])
if rec is None or rec["exp"] <= now:
return self._send(200, {"active": False})
out = dict(rec, active=True)
return self._send(200, out)
return self._send(404, {})
def log_message(self, *a): pass
server = ThreadingHTTPServer(("127.0.0.1", PORT), AS)
threading.Thread(target=server.serve_forever, daemon=True).start()
def post(path, **params):
data = uparse.urlencode(params).encode()
req = ureq.Request(f"http://127.0.0.1:{PORT}{path}", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with ureq.urlopen(req) as r:
return r.status, json.loads(r.read())
except uerr.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
print("=== extension point 1: grant_type (what the client presents) ===")
s, r = post("/token", grant_type="client_credentials")
print("grant_type=client_credentials ->", s, "| client acts on its own behalf, token:", r["access_token"][:14] + "...")
bearer1 = r["access_token"]
s, r = post("/token", grant_type="urn:example:otp")
print("grant_type=urn:example:otp ->", s, "| URN-namespaced extension grant, user-bound token:", r["access_token"][:14] + "...")
s, r = post("/token", grant_type="made-up-grant")
print("grant_type=made-up-grant ->", s, r)
print()
print("=== extension point 2: token_type (how the resource server must use it) ===")
def resource_server(token, token_type):
if token_type == "bearer":
# opaque: RS cannot read it -> ask the AS
_, info = post("/introspect", token=token)
return ("allowed" if info.get("active") and "billing:read" in info["scope"].split()
else "denied", "via introspection round trip")
if token_type == "urn:example:signed":
_, body, sig = token.split(".", 2)
payload = bytes.fromhex(body)
ok = hmac.new(LOCAL_KEY, payload, hashlib.sha256).hexdigest() == sig
rec = json.loads(payload)
return ("allowed" if ok and rec["exp"] > now and "billing:read" in rec["scope"].split()
else "denied", "verified locally, no round trip")
return ("denied", "unknown token type: client MUST NOT use a token it cannot use")
s, r = post("/token", grant_type="client_credentials") # bearer/opaque
print("bearer (opaque) ->", resource_server(r["access_token"], r["token_type"]))
signed = signed_token(scope="billing:read", sub="client:" + CLIENT_ID)
print("urn:example:signed ->", resource_server(signed, "urn:example:signed"))
print("opaque token, type 'mac' ->", resource_server(bearer1, "mac"))
Run:
=== extension point 1: grant_type (what the client presents) ===
grant_type=client_credentials -> 200 | client acts on its own behalf, token: opaque-092cbb2...
grant_type=urn:example:otp -> 200 | URN-namespaced extension grant, user-bound token: opaque-77a17bd...
grant_type=made-up-grant -> 400 {'error': 'unsupported_grant_type', 'error_description': 'unknown grant type: made-up-grant'}
=== extension point 2: token_type (how the resource server must use it) ===
bearer (opaque) -> ('allowed', 'via introspection round trip')
urn:example:signed -> ('allowed', 'verified locally, no round trip')
opaque token, type 'mac' -> ('denied', 'unknown token type: client MUST NOT use a token it cannot use')
The last line of the second block is the contract working as designed: the same opaque token string is accepted when the server treats it as bearer (introspect it) and rejected when it’s labeled mac — the server’s decision tree is driven by the type name, not by sniffing the token’s shape. A server that can’t use a token type refuses it rather than guessing. The urn:example:signed branch shows the alternative strategy a type name can select: local verification, no network round trip.
The introspection endpoint, end to end
Opaque bearer tokens create an asymmetry the core protocol never resolved: the client gets a random string it’s not supposed to parse, and the resource server that receives it can derive nothing from it. RFC 7662 fills that gap with a small, deliberately boring protocol: the resource server asks its authorization server “is this token active, and what do I need to know?” over HTTP, and gets a JSON object back.
The second demo stands up an introspection endpoint plus a resource-server-side validator, and runs the same endpoint against a small fleet of deliberately broken tokens.
Securing the endpoint
Three properties, all visible in the first block of output. The endpoint requires Basic authentication and answers invalid or missing credentials with 401 — RFC 7662 §2.3 says an authorization server responds with HTTP 401 when the introspection client’s credentials are invalid. It refuses GET with 405 and requires POST with form-urlencoded parameters: §7.5 explicitly endorses requiring POST, noting it prevents “the values of access tokens from leaking into server-side logs via query parameters.” And in production the whole thing rides on TLS — §2 says the endpoint “MUST be protected by a transport-layer security mechanism” — which these loopback demos skip for legibility.
What comes back for an active token
The second block of output is the full response for a healthy token, and it’s worth reading field by field, because RFC 7662 §2.2 defines exactly this set:
active— the only REQUIRED member. Boolean; true here.scope— space-separated scopes: “read:orders write:orders” in the output.client_id— the OAuth client that requested the token (shop-mobile).sub— the subject; §2.2 describes it as “usually a machine-readable identifier of the resource owner.”aud— intended audience, here the resource server’s own URI.iss— the issuer URI: which authorization server minted this token.exp/iat/nbf— Unix timestamps for expiration, issued-at, not-before (in the captured outputexpis exactly 3600 seconds afteriat; the absolute values just reflect the machine clock the run happened on).jti— a unique string identifier for the token.token_type—bearer, tying back to the extension point from the first section.
Notice what’s not in the public response. The internal record printed directly below it contains “username”: “[email protected]” — a human-readable identifier that §2.2 also lists as an optional response field. This demo’s authorization server deliberately withholds it: the resource server learns everything it needs to authorize from sub, the stable pseudonymous principal, and never has to store or process the user’s actual identity. That’s the pseudonymization pattern — sub is the handle; username is something you expose only if you have a reason to.
One more line in that block matters: both the unknown token and the revoked token get an identical, information-minimal body (active set to false). §2.2 requires exactly this: a properly-authorized introspection of a token that is inactive, nonexistent, or off-limits MUST return active=false, and SHOULD NOT include any additional information about an inactive token, including why. A server that says why a token is dead is leaking state to whoever is probing.
The validation sequence: active, aud, scope, then sub/client_id
RFC 7662 doesn’t mandate an order for the checks a resource server performs on an active response — the sequence below is the judgment call the field set invites. active gates everything (a dead token’s claims are not claims). aud comes next because it’s about the token’s destination: a token minted for another service must be rejected even if its scopes look plausible to you. Scope comes third — the actual permissions — and only then the identity gates: iss, sub, client_id. Putting identity last is defensible, not required: it’s the most deployment-specific check (it encodes your tenant roster and your per-client authorization), so the earlier, more universal checks fail loudly first with stable, comparable reasons.
Running that sequence over the demo’s token fleet:
[tok_good ] OK: tenant=https://as-a.example.com sub=sub-7f3a9c client=shop-mobile
[tok_wrong_aud ] REJECTED gate 2 aud: token was minted for a different service
[tok_no_scope ] REJECTED gate 3 scope: missing read:orders or write:orders
[tok_stranger ] REJECTED gate 4 subject: sub/client_id not authorized for this client
[tok_revoked ] REJECTED gate 1 active: introspection says token inactive/unknown
[tok_never_issued] REJECTED gate 1 active: introspection says token inactive/unknown
Each token fails at exactly the gate you’d expect it to: tok_wrong_aud has the right scopes but the wrong destination; tok_no_scope is well-minted but under-privileged; tok_stranger is fully valid in every field except that its subject isn’t authorized for this client; and the revoked/unknown pair are indistinguishable to the caller, as §2.2 wants.
One sub, many tenants
The last block is the subtlest. The same subject identifier — sub=‘alice’ — is a perfectly valid principal in two different tenants, as-a and as-b, and the only field that distinguishes them is iss. This isn’t an accident; it’s the design. RFC 7519 §4.1.2 says the sub value “MUST either be scoped to be locally unique in the context of the issuer or be globally unique” — so locally-scoped subjects are legal precisely because iss carries the context. A validator that keys identity on sub alone will conflate them; a validator that checks iss against a roster of trusted tenants first (as validate() in the code does, in gate 4) routes each token to its tenant and rejects the third one, issued by rogue.example.com:
[tok_alice_a] sub='alice' iss=https://as-a.example.com -> tenant resolved from iss, not sub
[tok_alice_b] sub='alice' iss=https://as-b.example.com -> tenant resolved from iss, not sub
[tok_alice_c] sub='alice' iss='https://rogue.example.com' -> REJECTED gate 4 issuer: unknown tenant
This is also where the username/sub distinction lands operationally: if the public response had carried [email protected] instead of (or alongside) sub, two tenants’ “alice” would be one collision-prone string in your audit log. iss + sub is the canonical, unambiguous principal.
The complete script:
"""RFC 7662 token introspection: endpoint security, response shape, and the
resource-server validation sequence (active -> aud -> scope -> sub/client_id)."""
import base64, json, secrets, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib import request as ureq, error as uerr, parse as uparse
PORT = 8102
ISS_A = "https://as-a.example.com"
ISS_B = "https://as-b.example.com"
AUD = "https://orders.example.com"
CLIENT_ID, CLIENT_SECRET = "orders-rs", "rs-secret"
now = int(time.time())
# Backing store: what the authorization server actually knows about each token.
STORE = {}
def issue(token, **rec):
rec.setdefault("iat", now)
rec.setdefault("nbf", now)
rec.setdefault("exp", now + 3600)
rec.setdefault("jti", "jti-" + secrets.token_hex(4))
rec.setdefault("token_type", "bearer")
rec.setdefault("aud", AUD)
rec.setdefault("iss", ISS_A)
rec.setdefault("client_id", "shop-mobile")
rec.setdefault("sub", "sub-" + secrets.token_hex(3))
rec.setdefault("username", None) # human identifier, kept internal
STORE[token] = rec
return token
good = issue("tok_good", sub="sub-7f3a9c", username="[email protected]",
scope="read:orders write:orders")
issue("tok_wrong_aud", aud="https://other.example.com", scope="read:orders write:orders")
issue("tok_no_scope", scope="read:invoices")
issue("tok_stranger", sub="sub-9999", scope="read:orders write:orders")
issue("tok_revoked", sub="sub-7f3a9c", scope="read:orders write:orders")
alice_a = issue("tok_alice_a", sub="alice", iss=ISS_A, scope="read:orders")
alice_b = issue("tok_alice_b", sub="alice", iss=ISS_B, scope="read:orders")
REVOKED = {"tok_revoked"}
class AS(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
# POST-only endpoint: keeps token values out of access logs
self.send_response(405)
self.send_header("Allow", "POST")
self.end_headers()
def do_POST(self):
if self.path != "/introspect":
return self._send(404, {})
auth = self.headers.get("Authorization", "")
expect = "Basic " + base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
if auth != expect: # token-scanning defense
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="introspect"')
self.end_headers()
return
form = uparse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
token = form.get("token", [None])[0]
if token is None:
return self._send(400, {"error": "invalid_request"})
rec = STORE.get(token)
active = rec is not None and token not in REVOKED and rec["exp"] > now
if not active:
return self._send(200, {"active": False}) # no extra info
pub = {k: rec[k] for k in ("active", "client_id", "sub", "scope",
"aud", "iss", "exp", "iat", "nbf", "jti",
"token_type") if rec.get(k) is not None}
pub["active"] = True # username never leaves
return self._send(200, pub)
def log_message(self, *a): pass
server = ThreadingHTTPServer(("127.0.0.1", PORT), AS)
threading.Thread(target=server.serve_forever, daemon=True).start()
def introspect(token, auth=True):
data = uparse.urlencode({"token": token}).encode()
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if auth:
tok = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
headers["Authorization"] = "Basic " + tok
req = ureq.Request(f"http://127.0.0.1:{PORT}/introspect", data=data, headers=headers, method="POST")
try:
with ureq.urlopen(req) as r:
return r.status, json.loads(r.read())
except uerr.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
print("=== endpoint security ===")
print("no credentials ->", introspect(good, auth=False)[0], "(401, Basic auth required)")
wrong = ureq.Request(f"http://127.0.0.1:{PORT}/introspect",
data=b"token=" + good.encode(),
headers={"Authorization": "Basic " + base64.b64encode(b"orders-rs:wrong").decode()})
try:
ureq.urlopen(wrong)
except uerr.HTTPError as e:
print("wrong secret ->", e.code)
req = ureq.Request(f"http://127.0.0.1:{PORT}/introspect", method="GET")
try:
ureq.urlopen(req)
except uerr.HTTPError as e:
print("GET (not POST) ->", e.code, "(token would land in access logs)")
print("unknown token ->", introspect("tok_never_issued")[1])
print("revoked token ->", introspect("tok_revoked")[1])
print()
print("=== full response field set for an active token (tok_good) ===")
_, pub = introspect("tok_good")
print(json.dumps(pub, indent=2, sort_keys=True))
print()
print("internal record (what the AS stores):", json.dumps({k: STORE[good][k] for k in ("client_id", "sub", "username", "scope", "aud", "iss", "exp", "iat", "nbf", "jti", "token_type")}, sort_keys=True))
print("'username' never appears in the public response above")
print()
print("=== validation sequence: active -> aud -> scope -> sub/client_id ===")
ALLOWED_ISSUERS = {ISS_A, ISS_B}
ALLOWED_SUBJECTS = {"sub-7f3a9c", "alice"}
def validate(token):
_, info = introspect(token)
if not info.get("active"):
return "REJECTED gate 1 active: introspection says token inactive/unknown"
if AUD not in (info["aud"] if isinstance(info["aud"], list) else [info["aud"]]):
return "REJECTED gate 2 aud: token was minted for a different service"
scopes = info.get("scope", "").split()
if "read:orders" not in scopes or "write:orders" not in scopes:
return "REJECTED gate 3 scope: missing read:orders or write:orders"
if info["iss"] not in ALLOWED_ISSUERS:
return "REJECTED gate 4 issuer: unknown tenant"
if info["sub"] not in ALLOWED_SUBJECTS or info["client_id"] != "shop-mobile":
return "REJECTED gate 4 subject: sub/client_id not authorized for this client"
return f"OK: tenant={info['iss']} sub={info['sub']} client={info['client_id']}"
for t in ("tok_good", "tok_wrong_aud", "tok_no_scope", "tok_stranger", "tok_revoked", "tok_never_issued"):
print(f"[{t:16s}] {validate(t)}")
print()
print("=== same sub, two tenants: iss is the disambiguator ===")
for t in ("tok_alice_a", "tok_alice_b"):
_, info = introspect(t)
print(f"[{t}] sub={info['sub']!r} iss={info['iss']} -> tenant resolved from iss, not sub")
untrusted = "tok_alice_c"
issue(untrusted, sub="alice", iss="https://rogue.example.com", scope="read:orders write:orders")
print(f"[{untrusted}] sub='alice' iss='https://rogue.example.com' -> {validate(untrusted)}")
Its full run (endpoint security, response shape, validation table, tenant disambiguation, in that order):
=== endpoint security ===
no credentials -> 401 (401, Basic auth required)
wrong secret -> 401
GET (not POST) -> 405 (token would land in access logs)
unknown token -> {'active': False}
revoked token -> {'active': False}
=== full response field set for an active token (tok_good) ===
{
"active": true,
"aud": "https://orders.example.com",
"client_id": "shop-mobile",
"exp": 1790168676,
"iat": 1790165076,
"iss": "https://as-a.example.com",
"jti": "jti-a73b2080",
"nbf": 1790165076,
"scope": "read:orders write:orders",
"sub": "sub-7f3a9c",
"token_type": "bearer"
}
internal record (what the AS stores): {"aud": "https://orders.example.com", "client_id": "shop-mobile", "exp": 1790168676, "iat": 1790165076, "iss": "https://as-a.example.com", "jti": "jti-a73b2080", "nbf": 1790165076, "scope": "read:orders write:orders", "sub": "sub-7f3a9c", "token_type": "bearer", "username": "[email protected]"}
'username' never appears in the public response above
=== validation sequence: active -> aud -> scope -> sub/client_id ===
[tok_good ] OK: tenant=https://as-a.example.com sub=sub-7f3a9c client=shop-mobile
[tok_wrong_aud ] REJECTED gate 2 aud: token was minted for a different service
[tok_no_scope ] REJECTED gate 3 scope: missing read:orders or write:orders
[tok_stranger ] REJECTED gate 4 subject: sub/client_id not authorized for this client
[tok_revoked ] REJECTED gate 1 active: introspection says token inactive/unknown
[tok_never_issued] REJECTED gate 1 active: introspection says token inactive/unknown
=== same sub, two tenants: iss is the disambiguator ===
[tok_alice_a] sub='alice' iss=https://as-a.example.com -> tenant resolved from iss, not sub
[tok_alice_b] sub='alice' iss=https://as-b.example.com -> tenant resolved from iss, not sub
[tok_alice_c] sub='alice' iss='https://rogue.example.com' -> REJECTED gate 4 issuer: unknown tenant
Server-side state: replay via jti, caching bounded by exp
Introspection answers “is this token alive right now?” — but a resource server has two concerns introspection doesn’t cover, because the authorization server doesn’t observe the token being used. The third demo builds both on top of the same protocol.
jti as the replay key
For a one-time grant — a single download, a one-shot confirmation — liveness isn’t the right property at all: the token is perfectly active between use #1 and use #2. RFC 7519 §4.1.7 says exactly this: the jti claim “can be used to prevent the JWT from being replayed,” by virtue of being a unique, non-reissuable identifier. So the resource server keeps its own consumption ledger keyed by jti:
1st use -> allowed: file delivered, jti=jti-dl-0001 recorded
fresh introspection still says active = True -> the AS never saw the token being used; only the RS does
2nd use -> denied: jti already consumed (one-time grant, replay)
The middle line is the whole lesson: after the first delivery, asking the authorization server again gets the same answer it gave before — active is still true. Use-state lives only at the resource server, and jti is the stable handle that lets that ledger key off something the token’s string doesn’t. (In the code this ledger is an in-memory dict, obviously — a real one is durable and shared across replicas.)
Caching, and the stale window it buys you
Every other request against a long-lived token shouldn’t pay for a network round trip. RFC 7662 §7.5 says a resource server MAY cache the introspection response to reduce load — with one hard bound: if the response contains the exp parameter, the response MUST NOT be cached beyond the time indicated therein. So the cache’s TTL is exp, full stop.
The demo mints a token that expires in 5 seconds and counts round trips. Requests 1 and 2 produce identical payloads, but the counter tells you which one hit the network:
req 1 -> introspection {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
req 2 -> cache hit {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
introspection round trips so far: 1
token revoked at the AS; req 3 -> cache hit {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
(still served from cache: the stale window runs until exp)
after exp passes, req 4 -> introspection {'active': False}
introspection round trips now: 2
Request 3 is the cost of the cache that §7.5 spells out: the token was revoked at the authorization server, yet the resource server still honored it from cache — the spec’s own words call this “a window during which a revoked token could be used.” When exp passes, the cache entry is forced out, the fresh introspection returns active=false, and the window closes. That’s the design trade-off in one loop: you trade liveness for load, and exp is the dial — a shorter-lived token is both safer to cache and a tighter revocation window.
The full script:
"""Server-side state on top of introspection:
1. replay prevention keyed by jti (one-time grant), and
2. local caching of introspection responses, bounded by exp,
with the revocation window that bounded caching implies."""
import base64, json, secrets, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib import request as ureq, error as uerr, parse as uparse
PORT = 8103
CLIENT_ID, CLIENT_SECRET = "downloads-rs", "dl-secret"
now = int(time.time())
STORE, REVOKED = {}, []
def issue(token, **rec):
rec.setdefault("exp", now + 120)
rec.setdefault("scope", "download:one-time")
rec.setdefault("aud", "https://downloads.example.com")
rec.setdefault("iss", "https://as.example.com")
rec.setdefault("client_id", "store-front")
rec.setdefault("sub", "sub-c9d2")
rec.setdefault("iat", now)
rec.setdefault("nbf", now)
rec.setdefault("jti", "jti-" + secrets.token_hex(4))
rec.setdefault("token_type", "bearer")
STORE[token] = rec
return token
one_time = issue("dl-once", jti="jti-dl-0001")
cached_tok = issue("dl-cached", exp=now + 5, scope="download:bulk")
class AS(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
form = uparse.parse_qs(self.rfile.read(int(self.headers.get("Content-Length", 0))).decode())
if self.path == "/introspect":
if self.headers.get("Authorization") != "Basic " + base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode():
return self._send(401, {})
rec = STORE.get(form.get("token", [None])[0])
active = rec is not None and rec["exp"] > time.time() and rec not in REVOKED
if not active:
return self._send(200, {"active": False})
return self._send(200, dict(rec, active=True))
if self.path == "/revoke":
rec = STORE.get(form.get("token", [None])[0])
if rec is not None:
REVOKED.append(rec)
return self._send(200, {"revoked": True})
return self._send(400, {"error": "invalid_request"})
return self._send(404, {})
def log_message(self, *a): pass
server = ThreadingHTTPServer(("127.0.0.1", PORT), AS)
threading.Thread(target=server.serve_forever, daemon=True).start()
def call(path, token):
data = uparse.urlencode({"token": token}).encode()
req = ureq.Request(f"http://127.0.0.1:{PORT}{path}", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()})
with ureq.urlopen(req) as r:
return json.loads(r.read())
introspections = 0
CACHE = {} # token -> (payload, until)
def introspect_cached(token):
global introspections
entry = CACHE.get(token)
if entry is not None and entry[1] > time.time():
return entry[0], "cache hit"
introspections += 1
payload = call("/introspect", token)
if payload.get("active"):
# RFC 7662 Sec 4: never cache beyond exp
CACHE[token] = (payload, payload["exp"])
return payload, "introspection"
print("=== replay prevention: jti is the stable key for one-time grants ===")
consumed = {} # jti -> expiry of consumption record
def use_one_time(token):
info, via = introspect_cached(token)
if not info.get("active"):
return "denied: token inactive"
if info["jti"] in consumed:
return "denied: jti already consumed (one-time grant, replay)"
consumed[info["jti"]] = info["exp"]
return f"allowed: file delivered, jti={info['jti']} recorded"
print("1st use ->", use_one_time(one_time))
fresh = call("/introspect", one_time)
print("fresh introspection still says active =", fresh["active"], "-> the AS never saw the token being used; only the RS does")
print("2nd use ->", use_one_time(one_time))
print()
print("=== local caching bounded by exp (token expires in 5s) ===")
CACHE.clear(); introspections = 0
print("req 1 ->", *reversed(introspect_cached(cached_tok)))
print("req 2 ->", *reversed(introspect_cached(cached_tok)))
print("introspection round trips so far:", introspections)
call("/revoke", cached_tok)
print("token revoked at the AS; req 3 ->", *reversed(introspect_cached(cached_tok)))
print(" (still served from cache: the stale window runs until exp)")
time.sleep(5.6)
print("after exp passes, req 4 ->", *reversed(introspect_cached(cached_tok)))
print("introspection round trips now: ", introspections)
Its complete run:
=== replay prevention: jti is the stable key for one-time grants ===
1st use -> allowed: file delivered, jti=jti-dl-0001 recorded
fresh introspection still says active = True -> the AS never saw the token being used; only the RS does
2nd use -> denied: jti already consumed (one-time grant, replay)
=== local caching bounded by exp (token expires in 5s) ===
req 1 -> introspection {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
req 2 -> cache hit {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
introspection round trips so far: 1
token revoked at the AS; req 3 -> cache hit {'exp': 1790165091, 'scope': 'download:bulk', 'aud': 'https://downloads.example.com', 'iss': 'https://as.example.com', 'client_id': 'store-front', 'sub': 'sub-c9d2', 'iat': 1790165086, 'nbf': 1790165086, 'jti': 'jti-b96ad8cd', 'token_type': 'bearer', 'active': True}
(still served from cache: the stale window runs until exp)
after exp passes, req 4 -> introspection {'active': False}
introspection round trips now: 2
Takeaway
OAuth 2.0’s extensibility is two slots — grant_type on the way in, token_type on the way out — with §8.1/§8.3 as the extension rules (a unique absolute URI) and the rest of the ecosystem as the standard fillings. For the most common payload, an opaque bearer token, RFC 7662 introspection is the canonical bridge: active as the hard gate, aud/scope as the universal checks, iss + sub as the unambiguous tenant-scoped principal, jti as the handle for use-state the server owns itself, and exp as the hard ceiling on how long any of it may be cached. None of that requires knowing how the token was minted — which is the whole point of keeping a resource server’s dependency on the authorization server’s internals down to a single, stable HTTP contract.