Tokens
What your project issues, what is inside each token, and how a resource server verifies one without calling you.
A completed flow produces up to three tokens. They are not interchangeable, and using the wrong one is the most common integration bug.
access_tokenfor calling APIsOptionalSent as Authorization: Bearer …. Says what the holder may do.
id_tokenfor knowing who signed inOptionalOnly issued with the openid scope. Says who the user is. Never send it to an
API as a credential — it is a statement about identity, not a grant of access.
refresh_tokenfor getting a new access tokenOptionalLong lived, no user interaction. Store it the way you would store a password.
The issuer
Everything anchors on one value: the issuer, the public address of your project's authorization server.
https://your-project.nulvless.co/authIt appears as iss in every token, and every endpoint in the discovery document
is built from it.
It has to match where discovery is served
This is not our rule, it is the specification's. OpenID Connect Discovery §4.3
requires the issuer in the document to be identical to the URL a client used
as the prefix to fetch it:
client is given https://your-project.nulvless.co/auth
client fetches https://your-project.nulvless.co/auth/.well-known/openid-configuration
document answers { "issuer": "https://your-project.nulvless.co/auth", … }
└── these two must be the same stringIf they differ, a conforming client rejects the provider outright — which is why the issuer is not a setting. There is exactly one value that works: the public URL where your project answers, which is the one the Console shows you. It is composed, not chosen, so there is no way to type it wrong.
A custom domain does not change it: the issuer stays on the project URL.
`.well-known` is just a reserved path
RFC 8615 sets aside /.well-known/ for
metadata that machines read. There is nothing special about it beyond the
convention — it is why handing a client one URL, the issuer, is enough for it to
find everything else.
It is not where your consent screen lives
Three different URLs show up in one flow, and conflating them is the usual source of confusion.
| Example | Whose it is | What it is for | |
|---|---|---|---|
| Issuer | https://acme.nulvless.co/auth | nulvless | Where the protocol lives: discovery, authorize, token, JWKS |
| Site URL + authorization path | https://acme.com/oauth/consent | you | Where the person decides. The page you build |
| Redirect URI | https://analytics.io/callback | the third-party app | Where the code goes at the end |
Your consent screen living on your own domain does not change the issuer. They are different layers, and the protocol only knows about the first.
In fact the consent screen is invisible to OAuth. It is not an endpoint, it has no name in the specification, and it never appears in the discovery document. As far as the third-party app is concerned, it sent a user to your authorize endpoint and the user came back to its callback with a code. What happened in between is your business.
Following one request through:
1. analytics.io → acme.nulvless.co/auth/oauth/authorize?… issuer
2. acme.nulvless.co → acme.com/oauth/consent?authorization_id=… your Site URL
3. acme.com → acme.nulvless.co/auth/oauth/authorizations/… issuer again
4. acme.com → analytics.io/callback?code=… redirect URI
5. analytics.io → acme.nulvless.co/auth/oauth/token issuer againTwo more consequences
- It is not the URL a request happened to arrive on. It is configuration, so
the same project reached by a different route still issues the same
iss. - Each environment has its own. A token from staging carries a staging issuer and will not validate against production, which is exactly what you want.
Discovery
Every OpenID Connect client starts here, and most libraries will do it for you if you hand them the issuer:
GET {issuer}/.well-known/openid-configuration{
"issuer": "https://your-project.nulvless.co/auth",
"authorization_endpoint": "…/oauth/authorize",
"token_endpoint": "…/oauth/token",
"userinfo_endpoint": "…/oauth/userinfo",
"jwks_uri": "…/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["RS256"]
}Read the endpoints from here rather than hardcoding them. It is the whole reason the document exists.
The document and the JWKS are public and cacheable — they carry
Cache-Control: public, max-age=… on purpose. Every other response from the
authorization server carries no-store.
Verifying an ID token
ID tokens are signed with RS256, so your API verifies them locally, against
the public keys at jwks_uri. No network call to nulvless on the hot path.
The access token is a different thing: it is signed with your project secret, not
with the key published at jwks_uri. Do not try to verify one against the JWKS —
use the ID token when you need to establish who the user is, and
userinfo when you need claims later.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const issuer = 'https://your-project.nulvless.co/auth';
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
export async function verifyIdToken(idToken: string) {
const { payload } = await jwtVerify(idToken, jwks, {
issuer,
audience: 'your-api',
});
return payload;
}Four things must be checked, and a library that skips any of them is not verifying:
Signatureagainst jwks_uriOptionalProves your project minted it.
issexact matchOptionalProves which project and environment. Skipping this lets a token from a different tenant through.
audexact matchOptionalProves the token was meant for your API and not forwarded from another.
expnot pastOptionalProves it is still live.
Key rotation
jwtVerify picks the key by the kid in the token header, so rotation is
transparent — as long as your JWKS cache is not older than the new key. jose
refetches on an unknown kid. If you cache the JWKS yourself, do the same, and
never cache it forever.
Reading the ID token
The ID token carries the claims for the scopes that were granted.
| Claim | Granted by |
|---|---|
sub | always — the stable user id |
iss, aud, exp, iat | always |
nonce | echoed from the authorization request |
email, email_verified | email |
name, picture | profile |
phone_number, phone_number_verified | phone |
Verify it the same way, and additionally check that nonce matches the one you
sent. sub is what you store as the foreign key for the account — not the email,
which can change.
Userinfo
The same claims, fetched with the access token, for when you want them fresh rather than as of sign-in:
curl {issuer}/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"Use the ID token when you need identity at login. Use userinfo when you need it later and the profile may have changed since.
Expiry and refresh
Access tokens are short lived; expires_in comes back with the token response.
Refresh before it lapses rather than waiting for a 401:
const { data } = await nulvless.auth.oauth.refreshToken({ refreshToken, clientId });If the refresh fails, the grant is gone — the user revoked it, or an administrator did. That is a sign-in prompt, not a retry.