Token
POST /oauth/token — exchange an authorization code or refresh an access token — and POST /oauth/revoke.
POST /oauth/token
Content-Type: application/x-www-form-urlencodedThe token endpoint. Supports two grant types: authorization_code (exchange the
code from the authorize step) and refresh_token
(rotate an expiring access token). Every response carries
Cache-Control: no-store.
Client authentication
Confidential clients authenticate with their secret, either as HTTP Basic auth or in the request body:
Authorization: Basic base64(client_id:client_secret)client_id=app_01H...&client_secret=cs_9f3a...Public clients send only client_id. Every client, public or confidential,
also sends the PKCE verifier for an authorization-code exchange.
private_key_jwt (RFC 7523)
A confidential app may instead register a public Ed25519 key set and never hold
a secret (RFC 20). Pass client_jwks (1–4 public Ed25519 JWKs with unique
kids, no private members) to App/create with confidential: true; no
client_secret is minted. Replace the keys later with App/update
client_jwks (same validation). Authenticate each token or revocation request
with a signed assertion instead of a secret:
client_id=app_01H...
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<compact EdDSA JWT>| Part | Requirement |
|---|---|
| Header | alg = EdDSA; kid names a registered key; no crit |
iss, sub | Both exactly the client_id (client_id in the body is optional but must match) |
aud | A single string: exactly the token endpoint URL from discovery (<issuer>/oauth/token), also at /oauth/revoke |
exp | In the future, at most 300 seconds after iat |
iat, nbf | At most 60 seconds in the future |
jti | 16–256 printable characters, single use until exp |
An assertion combined with a secret or a Basic header, a replayed jti, or a
secret presented for a private_key_jwt app is refused with invalid_client.
Discovery lists private_key_jwt in token_endpoint_auth_methods_supported.
OAuth 2.1 does not repeat redirect_uri in the token request. Supermail still
requires and exact-matches it during authorization.
Authorization code grant
| Field | Required | Description |
|---|---|---|
grant_type | yes | authorization_code |
code | yes | The code from the authorize redirect |
code_verifier | yes | The 43–128 character PKCE verifier matching the S256 challenge |
client_id | yes | Your application's id |
client_secret | confidential | If not using Basic auth |
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=ac_9f3a...
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=app_01H...{
"token_type": "Bearer",
"access_token": "<jwt>",
"expires_in": 600,
"refresh_token": "rt_5b2c...",
"id_token": "<jwt>",
"scope": "openid mail.read accounts.read"
}Refresh token grant
| Field | Required | Description |
|---|---|---|
grant_type | yes | refresh_token |
refresh_token | yes | The current refresh token |
client_id | yes | Your application's id |
client_secret | confidential | If not using Basic auth |
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=rt_5b2c...&client_id=app_01H...The response has the same shape as above and includes a new refresh_token.
Refresh tokens rotate: each refresh returns a new token and invalidates the old one. Reusing a consumed refresh token is treated as theft and revokes the entire token family. Always persist the newest refresh token and discard the previous.
Error responses
Failures follow OAuth 2.1 — an HTTP error status with a JSON body:
{ "error": "invalid_grant", "error_description": "authorization code expired" }Common error values: invalid_request, invalid_client, invalid_grant,
unauthorized_client, unsupported_grant_type, invalid_scope.
Revocation
POST /oauth/revoke
Content-Type: application/x-www-form-urlencodedRFC 7009 token revocation, for app-side cleanup (e.g. at logout). Authenticate
as the client (a secret, or a private_key_jwt assertion whose aud is the
token endpoint URL) and pass the token:
POST /oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=rt_5b2c...&client_id=app_01H...200 OK
Cache-Control: no-storeThe endpoint returns 200 OK for any valid client — even for an unknown or
already-invalid token — with an empty body. This is per RFC 7009: revocation
never discloses whether a token existed.