Documentation
OAuth ServerTokens

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 APIsOptional

Sent as Authorization: Bearer …. Says what the holder may do.

id_tokenfor knowing who signed inOptional

Only 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 tokenOptional

Long 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/auth

It 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 string

If 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.

Three different URLs show up in one flow, and conflating them is the usual source of confusion.

ExampleWhose it isWhat it is for
Issuerhttps://acme.nulvless.co/authnulvlessWhere the protocol lives: discovery, authorize, token, JWKS
Site URL + authorization pathhttps://acme.com/oauth/consentyouWhere the person decides. The page you build
Redirect URIhttps://analytics.io/callbackthe third-party appWhere 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 again

Two 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_uriOptional

Proves your project minted it.

issexact matchOptional

Proves which project and environment. Skipping this lets a token from a different tenant through.

audexact matchOptional

Proves the token was meant for your API and not forwarded from another.

expnot pastOptional

Proves 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.

ClaimGranted by
subalways — the stable user id
iss, aud, exp, iatalways
nonceechoed from the authorization request
email, email_verifiedemail
name, pictureprofile
phone_number, phone_number_verifiedphone

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.

Next

On this page