Quickstart
Enable the OAuth Server, register a client, and complete one authorization from end to end.
Fifteen minutes, and at the end a third-party app holds a token minted by your project.
Turn the server on
In the Console, open OAuth Server for the environment you are working in.
Three settings decide how the flow behaves:
Enabledoff by defaultOptionalWhile this is off, /oauth/authorize answers 403. Nothing else in your
project is affected.
Consent screen URLdefaults to your Site URLOptionalWhere your consent screen is hosted, if it is not on your Site URL. Leave it empty and the Site URL is used — the same one that carries confirmation links and welcome mail.
Authorization path/oauth/consentOptionalThe path of the consent screen, appended to the URL above.
The Console shows the resulting consent URL under the field. If it does not look like a page in your app, the Site URL is wrong.
Register a client
Still in OAuth Server, add an application. You need a name the user will recognise on the consent screen, and at least one redirect URI.
Name Acme Analytics
Redirect URI https://acme.example.com/callbackRedirect URIs match exactly — scheme, host, port and path. https://acme.example.com/callback
does not match https://acme.example.com/callback/ or …/callback?x=1. This is
an OAuth 2.1 requirement, and it is what stops an attacker redirecting your
users' codes somewhere else.
You get back a client_id, and a client_secret if the app can keep one. The
secret is shown once.
Build the consent screen
The client will send users to your Authorization path. That page has to exist
before the flow can complete — it is where the user sees what is being asked and
says yes.
It is three calls: read the request, render it, record the answer.
const { data } = await nulvless.auth.oauth.getAuthorization(authorizationId);
// data.client.name, data.client.logoUri, data.scope, data.autoApproved
const { data: result } = await nulvless.auth.oauth.consent(authorizationId, 'approve');
redirect(result.redirect.uri);Building the consent screen has this in Next.js, Nuxt, SvelteKit and Remix, with the parts that are easy to get wrong.
Send a user through
From the client application, with PKCE:
const verifier = crypto.randomUUID() + crypto.randomUUID();
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
const url = new URL(`${issuer}/oauth/authorize`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', 'https://acme.example.com/callback');
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
location.assign(url.toString());Keep verifier and state where the callback can read them. The verifier is the
proof that the app finishing the flow is the one that started it.
Exchange the code
The user approves, and your consent screen sends them back to the redirect URI
with ?code=…&state=…. Check that state matches, then trade the code:
const response = await fetch(`${issuer}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://acme.example.com/callback',
code_verifier: verifier,
client_id: clientId,
client_secret: clientSecret, // confidential clients only
}),
});
const { access_token, refresh_token, id_token, expires_in } = await response.json();id_token is present only if you asked for the openid scope. That is the one
that tells you who signed in.
Check it worked
Call the userinfo endpoint with the access token:
curl https://your-project.nulvless.co/auth/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"You should get back the claims covered by the scopes you asked for. If you get
401, the token is expired or was minted by a different environment.
Do not skip state
state is what stops a third party from tricking a signed-in user into
completing someone else's authorization. It costs one comparison in your
callback. The flow will work without it, right up until it doesn't.