Documentation
OAuth ServerAuthorization flow

Authorization flow

Every redirect and request in the authorization code flow, in order, with the parameters your project actually accepts.

One flow, because OAuth 2.1 removed the others: authorization code with PKCE. Public and confidential clients run the same sequence; the only difference is whether a client_secret travels with the token request.

Client appYour projectYour consent screenGET /oauth/authorizevalidates client and redirect_uri302 to your consent pathGET /oauth/authorizations/:idclient, scope, autoApprovedPOST …/consent { action }redirect.uri302 with code and statePOST /oauth/token { code, code_verifier }access_token, refresh_token, id_token

1. The authorization request

GET /oauth/authorize
  ?client_id=…
  &redirect_uri=https://acme.example.com/callback
  &response_type=code
  &scope=openid%20email
  &state=…
  &code_challenge=…
  &code_challenge_method=S256
  &nonce=…
  &resource=…
client_idrequiredOptional

The registered application.

redirect_urirequiredOptional

Must match one registered for the client, exactly.

response_typerequiredOptional

code. Nothing else exists in OAuth 2.1.

scopeoptionalOptional

Space separated. Omitted, it falls back to email.

staterecommendedOptional

Returned untouched on the redirect back. Your CSRF defence.

code_challengerequiredOptional

Base64url of the SHA-256 of your verifier.

code_challenge_methodrequiredOptional

S256.

nonceoptionalOptional

Echoed into the ID token. Binds the token to this request.

resourceoptionalOptional

The API the token is meant for, when your project protects more than one.

If the OAuth Server is disabled for the environment, this endpoint answers 403 before anything else happens.

2. Where errors go

This is the part integrations get wrong, and the rule comes straight from the spec: where an error surfaces depends on whether the redirect URI has been validated yet.

Before validationJSON, 400Optional

Unknown client_id, missing client_id, a redirect_uri that does not match. These cannot redirect — sending an error to an unverified URI would hand an attacker a working redirector. They come back as JSON.

After validation302 to redirect_uriOptional

Unsupported scope, bad response_type, a denied consent. These redirect back with ?error=…&error_description=…&state=…, because the client is known to own that URI.

So a client that only handles ?error= on its callback will silently miss the first class of failures. Handle both.

Once the request is valid, your project creates an authorization — a short-lived record of what was asked — and redirects the browser to:

{siteUrl}{authorizationPath}?authorization_id=…

Both halves come from your project's settings. The user is now in your application, and nulvless is out of the picture until your page calls back.

4. Reading and answering

Your consent screen fetches what was requested:

GET /oauth/authorizations/:authorization_id
{
  "client": { "id": "…", "name": "Acme Analytics", "logoUri": "…", "uri": "…" },
  "scope": ["openid", "email"],
  "autoApproved": false,
  "redirect": { "uri": "…" }
}

autoApproved is true when this user already granted these exact scopes to this client. Render nothing and complete the flow — asking again for permission someone already gave trains people to click through consent screens without reading them.

Then record the decision:

POST /oauth/authorizations/:authorization_id/consent
{ "action": "approve" }   // or "deny"

Either way the response carries the URI to send the browser to. On approve it holds the code; on deny it holds error=access_denied. Your page does not build that URL — it redirects to the one it is given.

This request is authenticated

Reading and answering an authorization require the signed-in user's session. That is what ties the consent to a person. If your consent page is reachable while signed out, send the user to your login screen first and return them with the authorization_id intact.

5. The token request

POST /oauth/token
{
  "grant_type": "authorization_code",
  "code": "…",
  "redirect_uri": "https://acme.example.com/callback",
  "code_verifier": "…",
  "client_id": "…",
  "client_secret": "…"
}

The code is single use and short lived. code_verifier must hash to the code_challenge from step 1 — that is PKCE, and it is what makes an intercepted code useless to anyone else.

{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "…",
  "id_token": "…"
}

id_token appears only when openid was among the scopes.

6. Refreshing

POST /oauth/token
{ "grant_type": "refresh_token", "refresh_token": "…", "client_id": "…" }

Same endpoint, different grant. The user is not involved — which is exactly why refresh tokens deserve the storage you would give a password.

What the user sees

Written out, the whole thing is four page loads: the client, your consent screen, back to the client, done. If your consent screen is fast and looks like the rest of your product, most users will not register that they left it.

On this page