Token Endpoint
The token endpoint is where your application exchanges credentials for access tokens. A grant type is the way an app proves it may have a token. The endpoint supports several: authorization code (for interactive user logins), client credentials (for service-to-service calls), refresh token (for extending sessions without re-prompting the user), device code (sign in on another device), CIBA (approval on the user's own device), token exchange (for delegation and impersonation) and the JWT bearer grant (redeeming a signed assertion from a trusted identity provider, used by Cross App Access). It is the OAuth 2.0 token endpoint; terms are explained in the glossary.
POST /orgs/{orgId}/api/v1/oauth/token
Do this
- Authenticate the client:
-u "CLIENT_ID:CLIENT_SECRET"(HTTP Basic) for confidential clients,client_idalone for public clients. - Send the grant parameters as
application/x-www-form-urlencoded. - Validate the returned tokens: the
issclaim must equal the discoveryissuer, the signature must verify againstjwks_uri, andaudmust contain your client or API. See Token formats.
Authorization Code Grant
Exchange an authorization code (received from the authorize endpoint) for access and refresh tokens. This is the most common flow for user authentication.
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | authorization_code |
code | Yes | The authorization code from the callback |
redirect_uri | Yes | Must match the URI used in the authorization request |
client_id | Yes | Your application's client ID |
client_secret | Confidential clients | Your application's client secret (not needed for public clients with PKCE) |
code_verifier | If PKCE used | The original code_verifier that matches the code_challenge |
Example Request
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://myapp.com/callback" \
-d "client_id=abc123def456" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Client Credentials Grant
Authenticate your application as itself, without a user. Use this for server-to-server communication, cron jobs, background workers, or AI agents.
- Client App to Auth Server: POST /token (client_credentials)
- Auth Server to Client App: Access Token
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | client_credentials |
client_id | Yes | Your application's client ID |
client_secret | Yes | Your application's client secret |
scope | No | Space-separated scopes (defaults to the client's registered scopes) |
Example Request
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=read:reports write:data"
Response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:reports write:data"
}
Refresh Token Grant
Get a new access token using a refresh token, without requiring the user to log in again.
A refresh token is issued only to clients whose grant types include refresh_token (authorization code, device and CIBA grants alike); otherwise the response simply has no refresh_token. Each refresh token lives for the client's refresh-token lifetime (default 30 days, 1 day to 1 year), and the whole rotation chain ends 90 days after sign-in, or after the client lifetime if that is longer. Rotation never extends that limit. See Refresh tokens.
A refresh token is refused with invalid_grant once its user is blocked, deactivated or deleted, signs out everywhere, or has their password changed or reset.
- Client App to Auth Server: POST /token (refresh_token)
- Note: Validate Refresh Token
- Auth Server to Client App: New Access Token (+ New Refresh Token)
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | refresh_token |
refresh_token | Yes | The refresh token from a previous token response |
client_id | Yes | Your application's client ID |
client_secret | Confidential clients | Your application's client secret |
Example Request
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..."
LumoAuth rotates refresh tokens. Each time you use a refresh token, a new one is issued and the old one is invalidated. If the old token is used again, the entire token family is revoked — this detects theft.
Token Exchange Grant
Token exchange lets a service swap one token for another, with a narrower audience or on behalf of another party. It is the mechanism behind delegation and impersonation, and enables scenarios like:
- Impersonation — a service acts as a user.
- Delegation — a service acts on behalf of a user, with an audit trail.
- Workload identity — exchange external tokens (AWS, Kubernetes) for LumoAuth tokens.
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | Yes | The token representing the subject (user or service) |
subject_token_type | Yes | What kind of token subject_token is; see Subject and actor token types |
actor_token | For delegation | Token of the service acting on behalf of the subject |
actor_token_type | If actor_token | What kind of token actor_token is; same values and rules as subject_token_type |
requested_token_type | No | Defaults to an access token. Special values select the Cross App Access identity assertion or a transaction token instead |
resource | No | Audience of the new token; see Resource indicators |
Subject and actor token types
Every token type names exactly one kind of credential. The server checks the
token's JOSE typ header and claims against the declared type and refuses
anything else, so a token can only be exchanged for what it was issued to be.
subject_token_type / actor_token_type | Accepted | Resolves to |
|---|---|---|
urn:ietf:params:oauth:token-type:access_token | An access token issued by this organization: an opaque token, or a JWT with typ: at+jwt | The user or agent the token was issued to |
urn:ietf:params:oauth:token-type:jwt | A JWT access token issued by this organization (typ: at+jwt), or an external workload identity token (Kubernetes, AWS, GCP, Azure, GitHub, SPIFFE) presented by a registered agent, see Workload federation | The token's user, or the agent bound to the workload identity |
urn:ietf:params:oauth:token-type:id_token | An OpenID Connect ID token issued by this organization (typ absent or JWT, aud = the exchanging client) | The user the ID token identifies |
urn:ietf:params:oauth:token-type:refresh_token | A refresh token issued by this organization to the exchanging client | The user the refresh token belongs to |
Rules that apply to every type:
- Agents are identified by agent access tokens only. A JWT whose
subis an agent counts as the agent's credential only when it is an agent access token (typ: at+jwtwithis_agent: true). A JIT permission token also carries the agent'ssub, but it is task-scoped and route-bound: it is never accepted as a subject or actor token, so a downstream service that received one cannot turn it into the agent's standing identity. - Special-purpose JWTs are never subject or actor tokens. Back-channel logout tokens (
logout+jwt), transaction tokens, identity assertions (oauth-id-jag+jwt), DPoP proofs and JARM responses are signed with the same organization key but have their owntyp; the exchange refuses them whatever type is declared. A relying party that receives a logout token cannot exchange it for a new user token. - The subject must still be allowed to receive tokens. The exchange mints a new token, so a user who has been deactivated, blocked, deleted, or whose tokens were cut off (for example after a password reset) is refused with
invalid_grant, even when the subject token itself has not expired. - Audience is bound to the exchanging client. For a client-initiated exchange the subject token must have been issued to (or be audienced at) the calling client; a token minted for another relying party is refused.
A type that is not listed, or a token that does not match its declared type,
is answered with 400 invalid_grant (Invalid subject_token / Invalid actor_token). The response is the same in every refused case, so the
endpoint cannot be used to learn why a given token was rejected.
Delegation Example
Service B acts on behalf of User A. The resulting token has an act claim that records the chain of identity.
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_A_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=SERVICE_B_ACCESS_TOKEN" \
-d "actor_token_type=urn:ietf:params:oauth:token-type:access_token"
Resulting JWT Claims
{
"sub": "user_a",
"act": {
"sub": "service_b"
},
"iss": "https://app.lumoauth.dev/orgs/acme-corp/api/v1",
"exp": 1704067200
}
The act (actor) claim indicates that service_b is acting on behalf of user_a. This creates a complete audit trail. The token response also carries issued_token_type (urn:ietf:params:oauth:token-type:access_token) next to the usual fields.
DPoP: sender-constrained tokens
DPoP ties a token to a key your client holds, so a stolen copy cannot be replayed from another machine. Send a DPoP proof header (a short-lived JWT signed with that key) with any grant to receive a token bound to it (token_type: "DPoP"). Clients with DPoP-bound access tokens enabled must send one. The proof format is described on the FAPI 2.0 page.
Server-provided nonce (opt-in)
Organizations can require a server-provided nonce in every proof (settings.security.dpop_require_nonce: true). When it is on and your proof carries no fresh nonce, the token endpoint answers:
HTTP/1.1 400 Bad Request
DPoP-Nonce: 7c1e0b9a3f2d4e5a6b7c8d9e0f1a2b3c
Content-Type: application/json
{"error": "use_dpop_nonce", "error_description": "Authorization server requires a fresh nonce in the DPoP proof"}
Implement the retry once and it works everywhere:
- Read the
DPoP-Nonceresponse header. - Build a new proof with that value in the
nonceclaim (newjti, freshiat). - Repeat the request. Nonces are single-use and scoped to your
client_id.
Resource endpoints such as UserInfo do the same with 401 use_dpop_nonce. The setting is off by default; enable it only for clients that implement the retry.
Sender-constrained tokens (DPoP) bind an access token to a key the client holds, so a stolen token cannot be replayed from another machine. The standard lets the server hand out nonces to limit how long a pre-computed proof stays valid. To comply, handle use_dpop_nonce by retrying with the nonce from the DPoP-Nonce header.
Error Responses
Token endpoint errors follow the OAuth 2.0 specification:
{
"error": "invalid_grant",
"error_description": "Authorization code is invalid, expired, or revoked"
}
| Error | HTTP | Description |
|---|---|---|
invalid_request | 400 | Missing or invalid parameters |
invalid_client | 401 | Client authentication failed, or the client is unknown or disabled |
invalid_grant | 400 | Authorization code or refresh token is invalid or expired; PKCE verifier mismatch; DPoP key differs from the one bound to the code |
invalid_scope | 400 | Requested scope is not allowed for this client |
unsupported_grant_type | 400 | The grant type is not supported |
unauthorized_client | 403 | The client is not allowed this grant type (for example token exchange or the device grant is not enabled for it) |
invalid_target | 400 | A resource value is malformed or not registered for the client |
invalid_dpop_proof | 400 | The DPoP proof is malformed, expired, replayed, or missing for a DPoP-only client |
use_dpop_nonce | 400 | The organization requires a server nonce; retry with the DPoP-Nonce header value |
Common mistakes
- Validating
issagainsthttps://app.lumoauth.dev. The issuer includes the organization path (.../orgs/acme-corp/api/v1) or is the bare custom domain. Take it from discovery. - Reusing a refresh token. Rotation invalidates the old one; using it again revokes the whole family.
- Expecting a refresh token forever. The rotation chain has an absolute expiry (at least 90 days); after
invalid_grant, send the user through/authorizeagain. - No
refresh_tokenin the response. The client is not allowed therefresh_tokengrant. - Sending
client_secretfor a public client. Public clients authenticate withclient_idplus PKCE only. - Retrying a DPoP proof after
use_dpop_noncewithout a newjti. Every proof needs a freshjtiandiatas well as the nonce. - Calling the token endpoint for a client that was disabled in the portal. You get
invalid_client; re-enable the client or create a new one.
Revoking a token
POST /orgs/{orgId}/api/v1/oauth/revoke
Token revocation invalidates an access or refresh token before it expires, for example when a user signs out of your app. Revoking a refresh token also revokes the access tokens issued with it. Authenticate as the client the token was issued to (HTTP Basic or client_id/client_secret in the body; public clients send client_id alone) and post the token as application/x-www-form-urlencoded:
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/revoke \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." \
-d "token_type_hint=refresh_token"
| Parameter | Required | Description |
|---|---|---|
token | Yes | The access or refresh token to revoke |
token_type_hint | No | access_token or refresh_token; speeds up the lookup |
The answer is always 200 with an empty JSON object ({}), whether or not the token existed, so a caller cannot probe which tokens are valid. Errors: 400 invalid_request (no token), 400 unsupported_token_type, 401 invalid_client, 429 too_many_requests (100 calls per minute). The discovery document lists the endpoint as revocation_endpoint.
Related
- Authorization endpoint
- PKCE
- Token formats
- Token introspection — ask whether a token is still valid
Standards used on this page
| Plain name | Standard |
|---|---|
| OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 |
| Device authorization (sign in on another device) | RFC 8628 |
| Client-initiated backchannel authentication (CIBA) | OpenID CIBA Core 1.0 |
| Token exchange (delegation) | RFC 8693 |
| JWT client authentication and the JWT bearer grant | RFC 7523 |
| Proof Key for Code Exchange (PKCE) | RFC 7636 |
| Resource indicators | RFC 8707 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
| Token revocation | RFC 7009 |
See the standards reference for what each one covers and how LumoAuth uses it.