Tokens
What an access token carries, how long it lives, and how to verify it.
Every successful grant at /oauth/token returns an access token, a signed
JWT your API verifies on its own. Most grants also return a refresh token
to renew it. Parameters for each grant are in the API reference.
{
"token_type": "Bearer",
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9…",
"expires_in": 3600,
"refresh_token": "eyJ…"
}
The access token
An RS256-signed JWT. The header is always:
{ "alg": "RS256", "typ": "JWT", "kid": "<RFC 7638 thumbprint>" }
kid names the key that signed it, in your org’s key set. Each client has its
own signing key, so the kid changes with the client.
A user token
What a person signing in to your app gets:
{
"iss": "https://acme.loginwith.dev/",
"aud": "cli_6a97e5823b3991093686878c",
"sub": "usr_65f1c0a2b4d3e5f6a7b8c9d0",
"typ": "user",
"tnt": "tnt_65f1c0a2b4d3e5f6a7b8c9d1",
"sid": "…",
"amr": ["pwd", "otp", "mfa"],
"acr": "aal2",
"jti": "…",
"iat": 1760774400,
"exp": 1760778000
}
| Claim | Meaning |
|---|---|
iss | Your org’s issuer, https://{slug}.loginwith.dev/ — trailing slash included. Compare it byte for byte. |
aud | The client_id the token was minted for. Not the org, not an origin: a SPA and its backend are two clients on one origin, and only the client id tells them apart. |
sub | The user id (usr_…). Never the email — addresses change hands. |
typ | What kind of token this is: user, console-user or client (below). Check it. |
tnt | The tenant the user is acting in (tnt_…). |
sid | The sign-in this token belongs to. Every token renewed from the same sign-in shares it; it is what revocation acts on. |
amr | How the session authenticated (RFC 8176): pwd for a password, mail for a magic link, then the second factor if one was completed — otp, sms, swk (passkey), hwk (security key), user (the key verified the user) — followed by mfa. Empty for a provider sign-in: we do not read the provider’s own methods. |
acr | The assurance level reached, after NIST SP 800-63B: aal1 for one factor, aal2 once a second factor is completed, aal3 for a hardware key that verified the user or was combined with a password. |
jti | A unique id for this one token. |
iat, exp | Issued at and expiry, in whole seconds. |
act | Only on an impersonated session: { "sub": "usr_…" }, the admin acting as this user (RFC 8693). |
azp | Only on a promoted token: the public client the original token was minted for. |
There is no email, name or picture in the token. The profile is read from
the UserInfo endpoint, GET /users/me, with the access token as Bearer — so a
changed name is never stale for an hour, and a leaked token leaks less. It
returns sub, email, given_name, family_name, name, picture,
provider (how the account signs in) and labels (the user’s labels in the
token’s tenant). The claims_supported list in discovery covers both.
Token kinds
typ | Minted for | tnt holds |
|---|---|---|
user | A person signing in to one of your clients | A tenant id (tnt_…) |
console-user | A person signing in to a console-class client, which administers orgs | An org id (org_…) |
client | A confidential client acting as itself (client_credentials) | — |
A client token has sub and aud both set to the client id, and no tnt,
sid, amr or acr: there is no user and no session behind it.
Lifetimes
| Credential | Default lifetime |
|---|---|
| Access token | 1 hour |
| Refresh token | 30 days, renewed on every use |
| Authorization code | 30 seconds, single use |
expires_in in the token response is the seconds left until exp. There is no
other expiry to read: exp is the signed one.
Refresh tokens
Send grant_type=refresh_token with the refresh token and your client_id (a
confidential client also authenticates with its secret). You get a new access
token and a new refresh token; the one you sent is spent.
Treat the refresh token as opaque. It is not signed with your org’s keys and your API has no reason to read it.
- Rotation with reuse detection. Presenting a refresh token that was already
rotated away means two parties hold a copy. The whole sign-in is ended — both
the thief and the user are signed out, and every token sharing its
sidstops working on LoginWith. - No refresh token comes back for
client_credentials, for a promoted token, or for an impersonated session — the last lasts one access-token lifetime and cannot be renewed.
Revocation
POST /oauth/revoke with token and client_id ends the sign-in behind it
(RFC 7009). Send the refresh token; an
access token works too, even an expired one, since both name the same sid.
The answer is always 200, whether or not the token was live.
Switching tenant
A user who belongs to several tenants of your org moves between them without signing in again:
POST /oauth/token
Authorization: Bearer <current access token>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:loginwith:params:oauth:grant-type:switch-tenant&tenant_id=tnt_…
The target must be one GET /tenants lists for the user (GET /organizations for a console-user token). The
response is a new access and refresh token pair under a new sid, and the
previous sign-in is revoked — switching means leaving. Asking for the tenant
you are already in is refused. A console-user token switches between orgs the
same way, with an org id as tenant_id.
Token exchange (promotion)
A browser holds a token minted for your public client, and any backend that
trusts that aud would accept it. A backend that wants tokens only it could
have obtained holds a confidential client and trades the browser’s token
for one minted to itself (RFC 8693):
POST /oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
The new token keeps sub, tnt, sid, amr, acr and act. aud becomes
the confidential client, and azp records the public client it came from. It
never outlives the token it was exchanged for, and earns no refresh token. Only
user tokens can be promoted.
Verifying a token
Your org’s discovery document is at {iss}.well-known/openid-configuration,
and its keys at {iss}.well-known/jwks.json — https://acme.loginwith.dev/.well-known/jwks.json.
Each org publishes its own key set, holding one key per client; pick the one
matching the token’s kid. Responses are cacheable for an hour.
Check:
- The signature, with
RS256only. issequals your issuer exactly, trailing slash included.audequals the client id you expect.expis in the future.typis the kind your route accepts.
With jose:
import { createRemoteJWKSet, jwtVerify } from "jose"
const issuer = "https://acme.loginwith.dev/"
const jwks = createRemoteJWKSet(new URL(".well-known/jwks.json", issuer))
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience: "cli_6a97e5823b3991093686878c",
algorithms: ["RS256"],
})
if (payload.typ !== "user") throw new Error("not a user token")
Or let @loginwith/koa do it.