AUTH4D-155 Token introspection

Access tokens are signed and self-contained, so a server that verifies them offline keeps accepting one for up to five minutes after its session is revoked (AUTH4D-25 (repository file docs/api-spec/auth4d-25.md)). Token introspection (RFC 7662) lets a confidential server ask the auth origin whether the session behind an access token is still live, so revocation takes effect at once for that server.

Endpoint

POST /oauth/introspect?tenant_id={tenantId} with an application/x-www-form-urlencoded body. The tenant may also be sent as the tenant_id form field. Tenant discovery advertises the endpoint:

{
  "introspection_endpoint": "https://auth.auth4.dev/oauth/introspect?tenant_id={tenantId}",
  "introspection_endpoint_auth_methods_supported": ["client_secret_basic"]
}

Both discovery fields are optional in OidcDiscoveryDocumentSchema, so documents from older deployments still parse.

Field Required Notes
token Yes The access token to check. At most 16,384 characters.
token_type_hint No Accepted and ignored. Only access tokens can be introspected.
tenant_id No Alternative to the query parameter.

Client authentication

The caller must be an enabled confidential client (clientType: "server", tokenEndpointAuthMethod: "client_secret_basic") of the same active customer tenant, and must authenticate with HTTP Basic. The client ID and secret are form-encoded before Basic encoding, as in RFC 6749 section 2.3.1.

Everything else gets 401 {"error":"invalid_client"} with WWW-Authenticate: Basic realm="auth4", charset="UTF-8", before the token is looked at:

  • no credentials, or a client_id in the body without Basic credentials;
  • a public browser or native client, with or without an invented secret;
  • a wrong secret, a secret sent in the body (client_secret), or different client IDs in the body and in Basic credentials;
  • a disabled client, a client of another tenant, an unknown or suspended tenant.

The control-plane realm is never served: its ID is not a customer tenant ID, so the request gets 400 invalid_request.

Project owners and admins create server clients, and rotate or delete their secrets, through the management API and the console (AUTH4D-207 (repository file docs/api-spec/auth4d-207.md)).

Browsers

Introspection is for servers. A request with any Origin header, including the console and auth origins, gets 403 invalid_request without CORS headers and without checking credentials. Origins that are not on the deployment-wide list are refused at the runtime boundary with 403.

Rate limit

Each confidential client may make 600 introspection requests per minute. The bucket is kept in the tenant’s rate-limit Durable Object under a privacy-hashed key. Over the limit the response is 429 {"error":"slow_down"} with Retry-After in seconds. A short client-side cache, such as the server SDK’s, keeps a busy server well below this.

Response

200 with Cache-Control: no-store for every authenticated request with a token.

An active token:

{
  "active": true,
  "scope": "openid email",
  "client_id": "cli_…",
  "sub": "usr_…",
  "aud": "cli_…",
  "iss": "https://auth.auth4.dev/t/ten_…",
  "exp": 1791037100,
  "iat": 1791036800,
  "jti": "…",
  "token_type": "Bearer",
  "tenant_id": "ten_…",
  "sid": "ses_…",
  "auth4_guest": true
}

sid is present when the token is bound to a session, and auth4_guest when the token carries the guest claim (repository file docs/api-spec/auth4d-64.md). The response never includes profile data such as an email address.

A token is reported active only when all of these hold:

  • it is an access token (token_use: "access") signed by a current tenant signing key;
  • its issuer is this tenant’s issuer, its tenant_id is this tenant, and its audience is the client named in its client_id;
  • it has not expired;
  • its session is live in the session-state Durable Object, the authority for revocation;
  • the token’s application is still enabled and the player is not disabled.

Anything else is {"active": false} with no other fields: revoked or expired sessions, tokens of another tenant, ID tokens, refresh tokens, and malformed or tampered values. RFC 7662 allows a server to answer active: false for token types it does not introspect.

If the session authority cannot be reached, the response is 503 temporarily_unavailable rather than an answer, so an outage is never reported as either active or revoked.

Which tokens a client may check

Any confidential client of a tenant may introspect any access token of that tenant, whichever application it was issued to. The response names the token’s own client_id, not the caller. This matches offline verification, which already lets the tenant’s servers read the same claims from the signed token; introspection only adds whether the session is still live. Tokens of other tenants are always active: false.

Server SDK introspect mode

@auth4/sdk-server gains an opt-in introspection option:

const auth4 = new Auth4ServerClient({
  tenantId,
  issuer,
  audience,
  introspection: { clientId, clientSecret, cacheTtlMs: 5_000 },
});
  • The token is verified offline first, exactly as before. Only a token that passes is introspected.
  • The endpoint is derived from the configured issuer’s origin (/oauth/introspect?tenant_id=…) and exposed as introspectionUrl. It is never read from a token or from discovery.
  • An active answer is reused for cacheTtlMs (default 5,000, at most 60,000, 0 disables reuse) and never past the token’s expiry. An inactive answer is kept until the token expires, because a revoked session does not come back. Concurrent checks of one token share one request. The cache is keyed by a SHA-256 of the token and holds at most 10,000 entries.
  • An active answer must name the verified token’s sub, client_id, jti and the configured tenant; otherwise the token is rejected with invalid_token.
  • An inactive answer rejects the token with invalid_token. A non-200 response, a network error, a timeout (introspectionTimeoutMs, default 3,000) or a body without a boolean active rejects it with the new error code introspection_unavailable. The SDK fails closed.
  • The client secret is kept in a private field. options omits the introspection settings, so the secret never appears in options or in JSON.stringify(client).

Without the option, behavior is unchanged.

Tests

tests/integration/auth4d-155.introspection.test.ts runs the unmodified API composition with real D1, session Durable Objects and rate-limit state. It covers discovery, active and revoked tokens, disabled players and applications, inactive token types, request validation, client authentication, disabled clients, tenant isolation, the tenant-wide client rule, browser refusal, the rate limit, and the SDK’s revocation, caching, credential handling and fail-closed behavior.

Source: docs/api-spec/auth4d-155.md