---
title: "Get your first token | FoxAuth"
description: "An Authorization Code plus PKCE walk-through with curl, from the challenge to a decoded ID token and a UserInfo call."
canonical: "https://foxauth.dev/docs/get-started/first-token/"
lastmod: "2026-10-18T12:04:13+03:00"
---
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.

Note

The endpoint paths below are this server’s defaults. Do not hard-code them in a real client. Read
GET /.well-known/openid-configuration and use what it advertises instead. That document also
reflects which capabilities are enabled, so it is the honest answer to “what does this deployment
support”.
This matters more than it used to. If your client belongs to a project you created, that
project is its own issuer: its endpoints live under <ISSUER>/<slug> and its discovery document is at
<ISSUER>/<slug>/.well-known/openid-configuration. A request for such a client sent to the bare
/auth is refused with unauthorized_client — “client is not authorized at this address” — because a
client may only be used at the address of the project it belongs to. The walk-through below uses the
default redfox project, which is served at the root.

On FoxAuth Cloud

Your client belongs to a project you created, so use that project’s issuer, not the bare host:
ISSUER=https://<name>.auth.foxauth.dev, as the project’s Overview shows it. Read $ISSUER/.well-known/openid-configuration and take the endpoints
from it. Below, replace http://localhost:3000 with $ISSUER, and demo with the client id the console
generated. The iss in step 3 and in the ID token is then your project’s issuer.

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.

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.

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.

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"

}

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.

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 sign-in screen, served for the project the client belongs to.

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.

On by default, or behind a flagSection titled “On by default, or behind a flag”

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.

PreviousRegister your first clientNextProtect your MCP server with OAuth
