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.
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_idrequiredOptionalThe registered application.
redirect_urirequiredOptionalMust match one registered for the client, exactly.
response_typerequiredOptionalcode. Nothing else exists in OAuth 2.1.
scopeoptionalOptionalSpace separated. Omitted, it falls back to email.
staterecommendedOptionalReturned untouched on the redirect back. Your CSRF defence.
code_challengerequiredOptionalBase64url of the SHA-256 of your verifier.
code_challenge_methodrequiredOptionalS256.
nonceoptionalOptionalEchoed into the ID token. Binds the token to this request.
resourceoptionalOptionalThe 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, 400OptionalUnknown 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_uriOptionalUnsupported 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.
3. The hand-off to your consent screen
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.