Documentation
OAuth ServerQuickstart

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 defaultOptional

While this is off, /oauth/authorize answers 403. Nothing else in your project is affected.

Consent screen URLdefaults to your Site URLOptional

Where 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/consentOptional

The 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/callback

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

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.

Next

On this page