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
}
ClaimMeaning
issYour org’s issuer, https://{slug}.loginwith.dev/ — trailing slash included. Compare it byte for byte.
audThe 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.
subThe user id (usr_…). Never the email — addresses change hands.
typWhat kind of token this is: user, console-user or client (below). Check it.
tntThe tenant the user is acting in (tnt_…).
sidThe sign-in this token belongs to. Every token renewed from the same sign-in shares it; it is what revocation acts on.
amrHow 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.
acrThe 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.
jtiA unique id for this one token.
iat, expIssued at and expiry, in whole seconds.
actOnly on an impersonated session: { "sub": "usr_…" }, the admin acting as this user (RFC 8693).
azpOnly 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

typMinted fortnt holds
userA person signing in to one of your clientsA tenant id (tnt_…)
console-userA person signing in to a console-class client, which administers orgsAn org id (org_…)
clientA 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

CredentialDefault lifetime
Access token1 hour
Refresh token30 days, renewed on every use
Authorization code30 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 sid stops 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:

  1. The signature, with RS256 only.
  2. iss equals your issuer exactly, trailing slash included.
  3. aud equals the client id you expect.
  4. exp is in the future.
  5. typ is 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.