Skip to content

Get your first token

This is one full Authorization Code + PKCE round trip against a local deployment, done by hand. Every client library implements this flow, and having walked through it once yourself usually makes debugging one much easier.

The examples assume ISSUER=http://localhost:3000 and a public client with the id demo whose registered redirect URI is http://localhost:8080/callback. Substitute your own from Register your first client.

  1. Generate a verifier and its challenge. PKCE is mandatory for every response_type=code request here, and S256 is the only accepted method.

    // save as pkce.ts, run with: bun pkce.ts
    const verifier = Buffer.from(
    crypto.getRandomValues(new Uint8Array(32))
    ).toString('base64url');
    const challenge = new Bun.CryptoHasher('sha256')
    .update(verifier)
    .digest('base64url');
    console.log({ verifier, challenge });

    Keep the verifier; you will send it to the token endpoint in step 4.

  2. Send the browser to the authorization endpoint. Build the URL with your challenge:

    http://localhost:3000/auth
    ?response_type=code
    &client_id=demo
    &redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback
    &scope=openid
    &state=8f2c1d
    &nonce=b41e07
    &code_challenge=<challenge>
    &code_challenge_method=S256

    (All on one line, without the indentation.) state is yours to check on the way back; nonce is echoed into the ID token so you can tie it to this request. scope=openid is what makes this an OpenID Connect request and produces an ID token. A refresh token also needs offline_access in the scope and prompt=consent in this URL and refresh_token among the client’s grant types. A console-created client has only authorization_code by default. If any of the three is missing the scope is dropped silently, with no error: you get a token response with no refresh_token in it. The server must also list offline_access among its supported scopes, as it does by default; while it does not, the token endpoint refuses every refresh token as unsupported_grant_type.

  3. Sign in and consent. The server redirects to its own interaction screens: a sign-in form for the project this client belongs to, then a consent screen listing the scopes (consent is on by default for a new client). Approving both sends the browser to

    http://localhost:8080/callback?code=<code>&state=8f2c1d&iss=http%3A%2F%2Flocalhost%3A3000

    Nothing needs to be listening on port 8080; copy the code out of the browser’s address bar. Check that state came back unchanged, and that iss is your issuer.

  4. Exchange the code at the token endpoint. The code is single-use and lives 60 seconds, so do this promptly. If you take longer, start again at step 2:

    Terminal window
    curl -s http://localhost:3000/token \
    -H 'Content-Type: application/x-www-form-urlencoded' \
    -d grant_type=authorization_code \
    -d code=<code> \
    -d client_id=demo \
    -d redirect_uri=http://localhost:8080/callback \
    -d code_verifier=<verifier>

    redirect_uri must be byte-identical to the one in step 2 — the grant compares it against the value recorded on the code and refuses a mismatch. For a confidential client, drop client_id from the body and authenticate instead: -u demo:<secret> for client_secret_basic, or -d client_id=demo -d client_secret=<secret> for client_secret_post.

    The response is the standard token response:

    {
    "access_token": "…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "id_token": "…",
    "scope": "openid"
    }
  5. Decode the ID token. It is a signed JWT; the payload is the middle segment, base64url:

    Terminal window
    echo '<id_token>' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

    Check iss against your ISSUER, aud against your client id, and nonce against step 2. A real client verifies the signature as well, against a key from GET /jwks selected by the token’s kid. The payload also says how the user signed in: amr is ["pwd"] for a password, or ["pwd", "otp", "mfa"] with a one-time code, so to require a second factor test for mfa, not for the claim being present.

  6. Call the UserInfo endpoint with the access token:

    Terminal window
    curl -s http://localhost:3000/userinfo -H 'Authorization: Bearer <access_token>' | jq .

    What comes back is governed by the scopes granted and by the claims configured on the server.

The two screens step 3 passes through, on a default deployment:

The server's sign-in screen: a username field, a password field, remember-me and a log-in button.
The sign-in screen, served for the project the client belongs to.
The consent screen naming the app, the account that is agreeing and what the app will be able to do.
The consent screen says what the app will be able to do. With `offline_access` and `prompt=consent` in the request, it also says until when the app keeps access.

Most endpoints are governed by a feature flag, and a flag that is off means the endpoint is not served at all. The refusal is indistinguishable, on purpose, from a path the server does not have. So a 404 on one of these is a configuration answer, not a defect:

  • Always available, with no flag: /health, /.well-known/openid-configuration, /.well-known/security.txt, /jwks, /auth and /token. Everything in this walk-through except step 6 is in this group.
  • On unless you turn it off: /userinfo (userinfo.enabled) and RP-initiated logout (rpInitiatedLogout.enabled).
  • Off until you turn it on: pushed authorization requests, introspection, revocation, dynamic client registration, the device flow, CIBA and the MCP control plane.

The full table, with the flag that governs each endpoint, is the endpoints reference; the flags themselves are in the settings reference. A setting takes effect when you save it.