Exchange an authorization code
Redeem the code from /oauth/authorize for an access token and a refresh token.
Redeems the code /oauth/authorize
sent back to your redirect_uri, with the PKCE verifier that answers its
challenge.
Authorization
The code and verifier are the credential; no Bearer token. The client authenticates as below.
Client authentication
Every grant on /oauth/token authenticates the client the same way:
| Client | How it authenticates |
|---|---|
| Public | client_id in the body, no secret (none). |
| Confidential | Its secret, either as Authorization: Basic base64(client_id:client_secret) (client_secret_basic) or as client_id + client_secret in the body (client_secret_post). |
A confidential client authenticates on every grant it uses, including
authorization_code and refresh_token. If it sends client_id in both the
header and the body, the two must agree. If the client has an IP allowlist,
requests from other addresses fail with invalid_client.
Body
Form-encoded (application/x-www-form-urlencoded).
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | authorization_code |
code | string | Yes | The code from the redirect. Single-use, and lives for seconds. |
code_verifier | string | Yes | The PKCE verifier matching the challenge sent to /oauth/authorize. Every code from /oauth/authorize carries a challenge. |
redirect_uri | string | Yes | The same string the authorization request carried. |
client_id | string | Yes | The client the code was issued to. |
client_secret | string | No | Confidential clients using client_secret_post. |
Response
200, with Cache-Control: no-store.
{
"token_type": "Bearer",
"access_token": "eyJ…",
"expires_in": 3600,
"refresh_token": "…"
}
No id_token is returned. Read the user’s profile from
/users/me. The token’s claims are in
Tokens.
Errors
Every refusal is 400 with an RFC 6749 code and nothing else: {"error": "invalid_grant"}.
| Status | error | When |
|---|---|---|
400 | invalid_request | code is missing, client_id is malformed, redirect_uri is missing when the authorization request carried one, or client_id differs between header and body. |
400 | invalid_grant | The code was rejected: unknown, expired, replayed, wrong verifier, wrong redirect_uri, issued to another client or another org. Deliberately not told apart. |
400 | invalid_client | Unknown client, a wrong secret, or a disallowed IP. |
400 | invalid_request | The host is not an org. |
400 | unsupported_grant_type | grant_type is not one of the five the endpoint accepts. |
Example
curl -X POST https://acme.loginwith.dev/oauth/token \
-d grant_type=authorization_code \
-d code=… \
-d code_verifier=… \
-d redirect_uri=https://app.acme.com/auth/callback \
-d client_id=cli_…