Skip to main content

Authorization code flow

This page walks through one complete sign-in, from the button in your app to the user's profile. Every request here is real and runnable once you have credentials.

Throughout, the example application is:

client_id8f14e45fceea167a5a36dedd4bea2543
client_secretkZ8mQ2vX7pL4nR9tY6wA3sD1fG5hJ0cV8bN2mK4xQ7E=
Redirect URIhttps://app.example.com/callback
Registered scopesprofile email

Step 1 — Send the user to Nanako​

Redirect the browser to the authorization endpoint:

https://api.nanako.org/oauth/authorize
?client_id=8f14e45fceea167a5a36dedd4bea2543
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&response_type=code
&scope=profile%20email
&state=f8c3de3d1b0e4a1
ParameterRequiredNotes
client_idYes
redirect_uriYesMust byte-match a registered URI
response_typeNoDefaults to code; code is the only accepted value
scopeNoDefaults to openid profile — send it explicitly
stateNoNot required by the server, but required of you — see below
code_challengeNoPKCE; see step 1a
code_challenge_methodNoDefaults to plain

Nanako validates the request in this order — client_id and redirect_uri present, response_type, the application exists and is enabled, the redirect URI is registered, then every requested scope is permitted. The first failure is returned as JSON; nothing is redirected to your callback. See Errors.

If the request is valid, the user's browser lands on Nanako's sign-in and consent screen.

Keep state URL-safe

state is echoed back into the callback URL without escaping. A value containing &, #, = or a space corrupts the callback: & injects a parameter, and # truncates everything after it into the fragment, so you lose state entirely.

Use a hex or unpadded-base64url random string. If you need structured state, keep it server-side and send an opaque lookup key.

state is not optional as a matter of engineering. It is your CSRF defence: generate it per attempt, bind it to the user's session, and reject any callback whose state does not match.

Step 1a — PKCE (optional)​

PKCE protects the authorization code in transit. Generate a random code_verifier, derive the challenge, and send it:

code_verifier=$(openssl rand -base64 60 | tr -d '\n=' | tr '/+' '_-')
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -binary -sha256 \
| openssl base64 | tr -d '\n=' | tr '/+' '_-')

Add &code_challenge=$code_challenge&code_challenge_method=S256 to the authorization URL, and keep code_verifier for step 3.

S256 is case-sensitive

Only the exact string S256 selects SHA-256. Any other value — including s256 — is treated as plain, meaning the server compares your verifier to the challenge literally. The mismatch surfaces at the token exchange as code_verifier 验证失败, an error that points at the verifier rather than at the typo that caused it.

Step 2 — Receive the code​

After the user approves, Nanako redirects to your callback:

https://app.example.com/callback?code=Xk9dQm2vR7pL4nT6wY3sA1fG5hJ0cV8bN2mK4xQ=&state=f8c3de3d1b0e4a1

Verify state matches what you stored, then read code. The code is 44 characters and ends in = — a standard query-string parser handles this correctly.

If the user declines, you receive ?error=access_denied with your state instead.

The code is valid for 10 minutes and can be redeemed once. Exchange it immediately; do not queue it, and do not retry a failed exchange with the same code.

Step 3 — Exchange the code for tokens​

curl -X POST https://api.nanako.org/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=Xk9dQm2vR7pL4nT6wY3sA1fG5hJ0cV8bN2mK4xQ=' \
--data-urlencode 'client_id=8f14e45fceea167a5a36dedd4bea2543' \
--data-urlencode 'client_secret=kZ8mQ2vX7pL4nR9tY6wA3sD1fG5hJ0cV8bN2mK4xQ7E=' \
--data-urlencode 'redirect_uri=https://app.example.com/callback'

For a PKCE client, replace client_secret with code_verifier.

Form-encoded, not JSON

The token endpoint reads form fields. A JSON body is not parsed — every field comes back empty and you get {"error":"unsupported_grant_type"}, which describes the symptom rather than the cause.

HTTP Basic authentication (client_secret_basic) is not supported either. Credentials go in the body.

redirect_uri must be sent again here, byte-identical to the one you used in step 1. It is compared against the value stored with the code.

The response:

{
"access_token": "aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5bC7dE9=",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "zY9xW7vU5tS3rQ1pN9mL7kJ5hG3fE1dC9bA7zY5xW3=",
"scope": "profile email"
}

scope is the granted scope. Check it — it is what the user actually approved, which may be narrower than what you asked for.

Step 4 — Call the API​

curl https://api.nanako.org/oauth/userinfo \
-H 'Authorization: Bearer aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5bC7dE9='
{
"sub": "0198c4a1-7f3e-7b21-9d04-1a2b3c4d5e6f",
"name": "hana",
"picture": "https://cdn.nanako.org/avatars/…",
"email": "hana@example.com",
"email_verified": true
}

sub is the stable, permanent identifier for this user. Key your own records on it. Do not key on email — a user can change it.

/oauth/userinfo is currently the only endpoint an OAuth access token can call. See Scopes.

Step 5 — Refresh before expiry​

Access tokens last two hours. Refresh with:

curl -X POST https://api.nanako.org/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=zY9xW7vU5tS3rQ1pN9mL7kJ5hG3fE1dC9bA7zY5xW3=' \
--data-urlencode 'client_id=8f14e45fceea167a5a36dedd4bea2543' \
--data-urlencode 'client_secret=kZ8mQ2vX7pL4nR9tY6wA3sD1fG5hJ0cV8bN2mK4xQ7E='

client_secret is mandatory here — there is no PKCE branch on this grant.

The response has the same shape as step 3, including a new refresh_token. Refresh tokens rotate on every use.

Persist the new refresh token before you use the new access token

The old refresh token is revoked the moment the new pair is issued. If your process crashes after the response arrives but before you have stored the replacement, your next refresh presents an already-rotated token — which Nanako treats as a stolen-token replay and responds to by revoking every access and refresh token for that user and application.

The user is signed out of your app and has to authorize again. Write the new refresh token to durable storage first, then proceed.

Concurrency has the same failure mode: two workers refreshing the same token at once will trip this. Serialize refreshes per user.

Rotation does not extend the chain. The replacement refresh token inherits the original expiry, so the whole chain ends 30 days after the user first authorized your app, however often you refresh. Handle invalid_grant / 刷新令牌已过期 by sending the user back to step 1.

Step 6 — Sign out​

To end a session early, revoke the token:

curl -X POST https://api.nanako.org/oauth/revoke \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=8f14e45fceea167a5a36dedd4bea2543' \
--data-urlencode 'client_secret=kZ8mQ2vX7pL4nR9tY6wA3sD1fG5hJ0cV8bN2mK4xQ7E=' \
--data-urlencode 'token=zY9xW7vU5tS3rQ1pN9mL7kJ5hG3fE1dC9bA7zY5xW3=' \
--data-urlencode 'token_type_hint=refresh_token'
token_type_hint is not optional here

RFC 7009 treats the hint as advisory. Nanako does not: without token_type_hint=refresh_token, the token is looked up among access tokens only. Sending a refresh token with no hint returns 200 {} and revokes nothing — a silent no-op that looks like success.

Revoke the two tokens in two calls, each with its own explicit hint.

A user can also revoke your application from their Nanako account settings. When they do, your next refresh fails with invalid_grant. Treat that as "consent withdrawn": clear your stored tokens and offer sign-in again rather than retrying.