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_id | 8f14e45fceea167a5a36dedd4bea2543 |
client_secret | kZ8mQ2vX7pL4nR9tY6wA3sD1fG5hJ0cV8bN2mK4xQ7E= |
| Redirect URI | https://app.example.com/callback |
| Registered scopes | profile 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
| Parameter | Required | Notes |
|---|---|---|
client_id | Yes | |
redirect_uri | Yes | Must byte-match a registered URI |
response_type | No | Defaults to code; code is the only accepted value |
scope | No | Defaults to openid profile — send it explicitly |
state | No | Not required by the server, but required of you — see below |
code_challenge | No | PKCE; see step 1a |
code_challenge_method | No | Defaults 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.
state URL-safestate 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-sensitiveOnly 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.
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.
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 hereRFC 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.