Tokens & Revocation

Everything integrators need to know about Signet tokens after the flow completes: lifecycles, refreshing, revoking, and checking validity in real time.

On-Behalf-Of (OBO)

Opt-in single-hop OBO lets confidential API A exchange a Signet user access token
addressed to A for a token addressed to API B. Send a form POST to /oauth/token
with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer,
requested_token_use=on_behalf_of, assertion, one resource, and scope.
Authenticate A with Basic or form credentials, never both. Duplicate/unknown
parameters and caller extra_claims are rejected.

Administrators enable OBO_ENABLED, set OBO_POLICIES_FILE with explicit
audience ownership and scope mappings, and add B to A’s resource allowlist.
Users must first consent to F accessing A and A accessing B using existing
authorization flows. Missing consent returns invalid_grant.

Output preserves the user’s sub, sets client_id=A, act.sub=client:<A ID>,
and aud=B. It lasts at most OBO_TOKEN_EXPIRATION (default/max 5m), the client
profile lifetime, or source expiry, whichever is earlier. No refresh/ID token
is issued, and OBO output cannot be exchanged again. act and may_act are
reserved issuer claims for all grants. Agent OBO and Entra integration are not
included.

Online validation checks the source, both consents, user, clients, and policy
without trusting a cached active verdict. Immediate revocation requires local
JWT validation plus uncached introspection; provision rate limits accordingly.
With ownership checks, B may receive only active, so always check audience
and claims locally too. Offline validation accepts tokens until expiry.
Disabling OBO rejects exchanges and delegated tokens online; wait out token
expiry and revoke remaining delegated records before downgrading Signet.

Scenarios, policy setup, both consent flows, and a complete exchange example: OBO integration guide.

Token Lifecycle

After a successful flow you hold one or more of:

Token Format Lifetime (per client profile) Used for
Access token JWT short 15m · standard 10h · long 24h (approx.) Authorization: Bearer on API calls
Refresh token JWT (treat as opaque) short 1d · standard 7d · long 30d (approx.) Exchanging for a new access token (/oauth/token)
ID token JWT Same as access token Client-side identity — see OIDC

Refresh tokens happen to be JWTs internally, but you should treat them as opaque — don’t parse their claims in client code; you gain nothing and risk coupling to an internal detail.

The exact numbers depend on the per-client token profile set by the administrator. Always trust expires_in from the token response — never hardcode.

Audience Binding (aud claim)

When the flow includes a resource=<URL> parameter (RFC 8707), the issued access token is signed with aud=<resource>. Without resource, aud falls back to the deployment-wide JWT_AUDIENCE config. Refresh tokens always use the static JWT_AUDIENCE and never carry the per-request resource. Resource servers should validate aud against their own identifier AND require type=access — see JWT Verification §Audience Binding.

Per-client allowlist (deny-all by default): a client may only bind aud to a resource value the administrator has added to that client’s allowed-resources allowlist. If the allowlist is empty, any resource= you pass is rejected with invalid_target — sending no resource (and taking the JWT_AUDIENCE fallback) still works. Ask your admin to allowlist each resource identifier your client needs. See Errors §Resource Indicator Errors.

Refreshing Tokens

At any point before the refresh token itself expires:

curl -X POST https://your-signet/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID"
# Confidential clients: add -u "$CLIENT_ID:$CLIENT_SECRET" instead of client_id in body

Response (same shape as the original token exchange).

Narrowing resource on refresh (RFC 8707 §2.2): optionally include resource=... to issue a new access token whose aud is a subset of the original grant. Widening (requesting a resource not in the original grant) returns 400 invalid_target — the refresh token is not consumed. Omit resource to receive a token bound to the full granted set.

When to refresh: proactively, e.g. 30–60 seconds before expiry, not on 401. This avoids mid-request failures and the noise of retry logic.

If your deployment uses rotation mode (next section), you must also serialize concurrent refreshes per session — two tabs refreshing at once will blow up the session.

Rotation Mode: the Reuse Detection Gotcha

Some Signet deployments run in rotation mode (ENABLE_TOKEN_ROTATION=true). In that mode:

  • Each refresh issues a new refresh token and invalidates the old one.
  • If the old refresh token is ever used again (e.g. a race between two tabs, a retry after a network hiccup, a stolen copy), Signet detects the reuse and revokes the entire token family.
  • Subsequent calls return {"error": "invalid_grant"}.

Practical implications for integrators:

  • Serialize refresh calls per user/session (mutex, single-flight). Two tabs refreshing simultaneously will both try to cash in the same old refresh token, one will win, and the other — with the just-invalidated old token — will trip reuse detection and kill the session.
  • Persist the new refresh token immediately. Don’t issue another request with the old one while you’re updating storage.
  • Treat invalid_grant on refresh as terminal — show a login screen; don’t retry.

You can’t tell from the token response alone whether rotation is on. If your integration must work on both modes, always persist the returned refresh_token (even if it looks identical — under rotation it will differ).

Sign Out — /oauth/revoke (RFC 7009)

On logout, revoke the refresh token (and optionally the access token) so a stolen copy is inert:

curl -X POST https://your-signet/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=REFRESH_TOKEN" \
  -d "token_type_hint=refresh_token" \
  -d "client_id=YOUR_CLIENT_ID"
# Confidential clients: include client_secret or use HTTP Basic
Parameter Required Values
token yes The token to revoke
token_type_hint no access_token or refresh_token
client_id yes Plus client_secret for confidential

Per RFC 7009, the endpoint returns 200 OK whether or not the token existed. Don’t rely on the response to tell you anything — just assume the token is gone.

Revoking a refresh token also invalidates the whole token family (in rotation mode). Revoking an access token does not automatically revoke the matching refresh token — revoke both, or revoke the refresh token on logout and let the short-lived access token expire on its own.

This endpoint also revokes a personal API key. Pass token=sgk_... with no client credentials — possession of the key is the authorization. Useful for a script that tears down its own short-lived key when the job finishes. See API Keys.

Caller-Supplied Extra Claims

/oauth/token accepts an optional extra_claims form parameter — a JSON object of additional claims to embed in the issued token(s). It works on all four grants (authorization_code, device_code, client_credentials, refresh_token).

curl -X POST https://your-signet/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode 'extra_claims={"tenant":"acme","region":"eu"}'

Rules and guards:

  • The value must be a JSON object. Malformed JSON, exceeding the size guards, or using a reserved key returns 400 invalid_request.
  • Default size guards (an operator may tune or disable each): ≤ 4096 bytes raw, ≤ 16 keys, ≤ 512 bytes per value.
  • Reserved keys are rejected: the standard RFC/OIDC/Signet-managed claims (iss, sub, aud, exp, iat, jti, type, scope, client_id, user_id, nonce, at_hash, …) cannot be overridden.
  • The whole feature can be disabled by the operator (EXTRA_CLAIMS_ENABLED=false), in which case any non-empty extra_claims is refused.
  • Embedded in the refresh token too. For grants that issue a refresh token (authorization_code, device_code, and a rotation-mode refresh_token exchange), the same claims are merged into the refresh-token JWT as well. You should treat refresh tokens as opaque, but they are decodable — factor these claims into your data-exposure model, and don’t put anything in extra_claims you wouldn’t want to live in a days-long token.
  • Stateless — not persisted. Claims are not stored with the grant, so you must re-supply extra_claims on every refresh to keep them in the new tokens.

Trust model: these claims are self-asserted by the caller, not attested by Signet. A resource server must treat them as untrusted input — never use an extra_claims value for an authorization decision as if Signet had vouched for it.

Checking Validity in Real Time

For local JWT verification at resource servers, see JWT Verification. That’s fast and scales horizontally but cannot detect revoked/disabled tokens — a revoked JWT stays cryptographically valid until exp.

When you need real-time revocation awareness, call one of these endpoints:

/oauth/introspect (RFC 7662) — Preferred

Requires client authentication (the calling service must itself be a registered Signet client):

curl -X POST https://your-signet/oauth/introspect \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=TOKEN_TO_CHECK" \
  -d "token_type_hint=access_token"

Response:

{
  "active": true,
  "scope": "openid profile email",
  "client_id": "client-uuid",
  "username": "alice",
  "token_type": "Bearer",
  "exp": 1700000000,
  "iat": 1699996400,
  "sub": "user-uuid",
  "iss": "https://your-signet",
  "jti": "unique-token-id"
}

If the token is invalid, expired, revoked, or disabled, the response is:

{ "active": false }

Ownership gate: by default (INTROSPECTION_REQUIRE_OWNERSHIP=true) the full metadata above is returned only for tokens your own client issued. If you introspect an active token that belongs to a different client, the response is stripped down to { "active": true } — no sub, scope, username, client_id, aud, exp, etc. Don’t build a resource server around cross-client introspection unless your operator has set the flag to false.

Use this for policy enforcement where freshness matters — admin dashboards, high-value operations, anything where you can’t tolerate a stale-validity window as long as the access-token lifetime (up to ~10h on the standard profile, 24h on long).

/oauth/tokeninfo — Lightweight Alternative

Takes the token as a Bearer header and returns a subset of fields. No client credentials needed (the token itself is the auth):

curl -H "Authorization: Bearer TOKEN_TO_CHECK" https://your-signet/oauth/tokeninfo
{
  "active": true,
  "user_id": "user-uuid",
  "client_id": "client-uuid",
  "scope": "openid profile email",
  "exp": 1700000000,
  "iss": "https://your-signet",
  "subject_type": "user"
}

subject_type is "client" for tokens issued by Client Credentials. Invalid tokens return 401 with an OAuth invalid_token error.

Personal API keys: both this endpoint and /oauth/introspect also accept an opaque sgk_ key, adding token_type: "personal_api_key" to the response so you can apply distinct policy. A key is not a JWT, so these two endpoints are the only way to verify one — see API Keys.

Signed-in users can paste either credential into Developer → Token Info (/account/token-info) to see its verified project, expiry, scopes, and subject metadata. The form uses POST, never puts the credential in the URL or result page, and records the query in the user’s audit log. Invalid, expired, revoked, and unknown credentials intentionally share one result.

Which One?

Need Use
Resource server validates tokens at scale, can tolerate short staleness Local JWKS verify (no call to Signet)
Need real-time revocation state, calling service can authenticate /oauth/introspect
Interactive check from within a user session Developer → Token Info (/account/token-info)
Calling service is holding the token itself /oauth/tokeninfo
Verifying an opaque sgk_ personal API key /oauth/tokeninfo, unless you authenticate as the app the key is bound to — local verification is impossible (API Keys)

Rate Limits

Signet rate-limits token-path endpoints per client IP — except /oauth/introspect, where each authenticated client app gets its own budget (so a fleet behind one egress IP is not one bucket), with a per-IP ceiling behind it. Defaults (an operator may tune these):

Endpoint Default limit
POST /oauth/token 20 req/min per IP
POST /oauth/device/code 10 req/min per IP
POST /device/verify 10 req/min per IP
POST /oauth/introspect 600 req/min per client app, 1200 per IP
GET /oauth/tokeninfo 600 req/min per IP
POST /account/token-info 600 req/min per IP (separate browser bucket)
GET/POST /oauth/userinfo 600 req/min per IP
POST /login 5 req/min per IP

Exceeded limits return 429 Too Many Requests. Honor any Retry-After header; otherwise back off exponentially. Batch your work — never validate a JWT by calling /oauth/tokeninfo or /oauth/introspect per request when you can verify locally via JWKS; the only credential that needs a round-trip is an sgk_ personal API key, and you should cache those verdicts briefly (API Keys).