Skip to main content

Path A, as a confidential client

Path A ran demo-spa, which is public. The other common deployment shape is a server-side web application that holds a secret, and a reader who is building one needs to see it walked rather than inferred.

It is the same journey. Discovery, /authorize, the login POST and /userinfo are what Path A showed, with client_id=demo-backend in place of demo-spa and nothing else changed — the same PKCE pair, the same login form, the same authorization code. One hop differs: at /token the client authenticates, in the one way it is registered for.

PKCE is not a public-client concern here​

A reader arriving from another provider will expect code_challenge to be optional for a client that has a secret, and will leave it out. Most providers make it a per-client setting; RFC 7636 introduced it for clients that cannot keep a secret, and OpenID Connect Core does not ask for it at all.

Odudu requires it of every client on every authorization_code request, with no exception and no per-client opt-out, following OAuth 2.1 (draft-ietf-oauth-v2-1-15 §4.1.1). That is not a small divergence: it is the entire reason the OpenID Foundation's Basic OP certification plan cannot pass here. Every module of that plan but its one dedicated PKCE module sends an authorization request carrying no code_challenge, and Odudu refuses each one — 28 of 35 modules, each failure individually confirmed to be this and nothing else. ADR 0016 records the decision and what was given up for it.

Omitting the challenge does not fall back to proving yourself with the secret instead. The request is refused at /authorize, before a secret would ever be presented:

curl -sS -D - -o /dev/null --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-backend' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid profile email' \
--data-urlencode 'state=xyz-123' \
'http://localhost:3000/tenants/demo/protocol/openid-connect/auth'
HTTP/1.1 302 Found
location: http://localhost:8080/callback?error=invalid_request&error_description=code_challenge+is+required&state=xyz-123&iss=http%3A%2F%2Flocalhost%3A3000%2Ftenants%2Fdemo
content-length: 0

The refusal travels back to the client as a redirect rather than a rendered page because client_id and redirect_uri were both good — the boundary is in /authorize: the render-versus-redirect boundary. state comes back so the client can match the answer to its request, and iss (RFC 9207) so it can tell which issuer refused.

Redeeming the code with client_secret_basic​

demo-backend is registered for client_secret_basic, so the secret goes in the Authorization header. curl -u builds it. There is no client_id parameter: the header already names the client.

curl -sS -D - -u demo-backend:demo-backend-secret \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode "code_verifier=$VERIFIER" \
'http://localhost:3000/tenants/demo/protocol/openid-connect/token'
HTTP/1.1 200 OK
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"id_token": "eyJhbGciOiJSUzI1NiIs…",
"refresh_token": "QBt3F9bUdMxbNZIQF3MD…",
"token_type": "Bearer",
"expires_in": 300,
"scope": "openid profile email"
}

(All three token values truncated.) $CODE and $VERIFIER are the ones The shell variables the rest of this document uses produces, with client_id=demo-backend substituted in the first two blocks.

The response is Path A's response, and so are the tokens inside it, except where they name the client:

{
"iss": "http://localhost:3000/tenants/demo",
"sub": "01a0cb07-4e76-…",
"aud": ["http://localhost:3000/tenants/demo"],
"client_id": "demo-backend",
"scope": "openid profile email",
"iat": 1790113245,
"exp": 1790113545,
"jti": "01a0cb10-6aed-…",
"sid": "01a0cb10-6ab6-…",
"grant_id": "01a0cb10-6aed-7396-b2e0-2383f0bb527a"
}
{
"sub": "01a0cb07-4e76-…",
"name": "ada",
"preferred_username": "ada",
"email": "ada@example.com",
"email_verified": false,
"iss": "http://localhost:3000/tenants/demo",
"aud": "demo-backend",
"iat": 1790113245,
"exp": 1790113545,
"nonce": "n-0S6_WzA2Mj",
"sid": "01a0cb10-6ab6-…",
"amr": ["pwd"],
"acr": "1"
}

(sub, kid and jti shortened; the headers are as Path A's.) sub is ada's, the same identifier demo-spa was given for her — a subject belongs to the tenant, not to the client that asked. The service-account subject a confidential client also carries is a different one, and only client_credentials mints tokens for it.

A refresh token, unlike client_credentials. A user authenticated here and is not going to stay at the keyboard, which is the situation refresh exists for; it rotates exactly as Path B describes, with the client authenticating on each refresh as it did here.

The same redemption with client_secret_post​

demo-post is registered for client_secret_post, so the same two values go in the form body instead, and client_id is sent because nothing else names the client:

curl -sS -D - \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-post' \
--data-urlencode 'client_secret=demo-post-secret' \
--data-urlencode "code_verifier=$VERIFIER" \
'http://localhost:3000/tenants/demo/protocol/openid-connect/token'
HTTP/1.1 200 OK
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"id_token": "eyJhbGciOiJSUzI1NiIs…",
"refresh_token": "vwguWAm5qJ2mPfXBBVhk…",
"token_type": "Bearer",
"expires_in": 300,
"scope": "openid profile email"
}

(Truncated as above. The tokens differ from the block before only in naming demo-post.)

Which of the two a client may use is registered, not chosen per request, and sending the other is invalid_client even with the right secret — the matrix is in Client authentication is by the registered method and no other.

A third method, private_key_jwt, is registered by no client on this stack — seed client cannot produce one, and the clients this document registers dynamically all leave token_endpoint_auth_method at its default — so no transcript for it appears here. POST /admin/tenants/{tenant}/clients (Admin paths) can produce one, and dynamic registration always could; see the roadmap's entry on odudu seed for why this document does not, and the /token refusal table for the bytes each of its failures answers with.

Presenting no secret at all is refused the same way. A confidential client cannot redeem a code as though it were public, however good the code and the verifier are:

curl -sS -D - \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-backend' \
--data-urlencode "code_verifier=$VERIFIER" \
'http://localhost:3000/tenants/demo/protocol/openid-connect/token'
HTTP/1.1 401 Unauthorized
www-authenticate: Basic realm="token"
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"invalid_client","error_description":"Client authentication failed."}

What the client does next: what Path A's client does, with one difference that matters operationally — the secret is held by the server, so the access and refresh tokens never need to reach the browser at all. That is the reason to be a confidential client.

Address verification​

email_verified is a stored claim, but until now nothing could set it truthfully — every ID token and UserInfo response above for ada carries "email_verified": false, and that is still real: nothing verifies an address until this section. /tenants/{tenant}/login-actions/action-token?key=… is the other half: a link a verification email carries, which a visit shows and a button press redeems. Self-registration below is one way to trigger that mail, for an address that does not exist yet; odudu seed --send-verification-email is the other, standing in for the admin console's "Send verification email" action against a user who already exists — ada, seeded back in Bootstrap. A tenant's verify_email is turned on with seed tenant --set, the same way Self-registration turns on the other two:

odudu seed tenant --name demo --set verify_email=true
{"command":"tenant","created":false,"tenant":"demo","tenantId":"01a0cb07-4e24-73d6-8060-137e69941e60","settings":["verify_email"]}
odudu seed \
--tenant demo --client demo-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com \
--send-verification-email

The command queues the message and returns; nothing is sent on its way out. The stack's own mail pass picks it up within ODUDU_OUTBOX_INTERVAL_SECONDS (Sending queued mail), so the capture below appears in docker compose logs odudu a moment later rather than in the seed command's own output. And with ODUDU_SMTP_HOST unset — true of the compose stack and of every way this document runs the server — nothing is actually delivered either: loggingSender writes the message it would have sent to the log and keeps nothing, which is how a reader without a mail server gets the link:

{
"level": 30,
"to": "ada@example.com",
"subject": "Verify your demo account",
"text": "Confirm your email address for ⁨demo⁩ by visiting this link:\n\nhttp://localhost:3000/tenants/demo/login-actions/action-token?key=F5EglJdZ3sw6QhoZJ-df2pm7R5NIFvub-AYg4HV6IBw\n\nIf you did not request this, you can ignore this message.\n\n--\nYou received this because this email address was used to create or update an account at ⁨demo⁩.\n© 2026 Odudu · Powered by odudu",
"htmlBytes": 5839,
"msg": "captured email — no SMTP host configured"
}

(One line of a larger pino JSON object, reduced to the fields that matter here. The log carries the size of the html part in bytes, htmlBytes, and never the markup: it is five kilobytes a message, and a log line is not the place for it. The key is shortened nowhere else in this document because a reader needs the whole thing to follow the link.)

The html part is a designed message, not bare markup: table-based and 560px wide, every style inline, the colours the console's own tokens compile to, a light palette that stands alone and a dark one a prefers-color-scheme block swaps in where the client reads it. It opens with a hidden line of preview text for the inbox list, then carries the tenant's display name (cut to eighty characters and an ellipsis if it is longer) beside the Odudu mark (an inline image the SMTP sender attaches, named cid:odudu-mark, with the word odudu as its alt text), a heading, the sentence, a button, the same address written out under it, a muted line on why the message was sent, and a footer whose year is the UTC year of the moment the message was written. The button and the written-out address are the only two anchors in it, and both carry the whole link. The stack sent the same message again through a relay, a Mailpit container, and the command reads the anchors from what Mailpit holds (ID is the message's id in Mailpit's list; its key is that send's, not the one above):

curl -s "http://localhost:18925/api/v1/message/$ID" | jq -r .HTML | grep -o '<a [^>]*>[^<]*</a>'
<a class="m-button-link" href="http://localhost:3000/tenants/demo/login-actions/action-token?key=odhQGLAhhS7ff880f_mHki4G35TclDgeGPo0bSMMtos" target="_blank" style="display:inline-block;font:600 15px/20px system-ui,-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif;color:#ffffff;text-decoration:none;mso-line-height-rule:exactly">Verify email</a>
<a class="m-link" href="http://localhost:3000/tenants/demo/login-actions/action-token?key=odhQGLAhhS7ff880f_mHki4G35TclDgeGPo0bSMMtos" target="_blank" style="color:#0a6344;text-decoration:underline;word-break:break-all">http://localhost:3000/tenants/demo/login-actions/action-token?key=odhQGLAhhS7ff880f_mHki4G35TclDgeGPo0bSMMtos</a>

The plain-text part carries the link on a line of its own, then the same reason line and footer after a -- line. The mails as a mail client draws them, in both palettes, at 600px and 375px, are in the mail gallery.

The text of every mail comes from the mail package's own message catalogue (en today), in the language of the recipient's locale where the sender knows it and in English otherwise. A tenant name is somebody else's text, so the plain-text part wraps it in Unicode isolates (U+2068 before it and U+2069 after, invisible in most viewers but present in the captures on this page), the HTML part in <bdi>, and the subject carries it with bidirectional controls and line breaks removed, since a header can hold neither. The four mail captures here are from a second run of the same three mails (demo, register-demo and reset-demo), with ODUDU_PUBLIC_BASE_URL set to http://localhost:3000 so the links read as the rest of these pages do. Their keys are that run's, so the commands below that follow a link follow the keys of the earlier run, not these.

Before following it, ada's ID token still reads the way every one earlier in this document does (here from a sign-in on this run's stack, before verify_email was turned on):

{ "email": "ada@example.com", "email_verified": false }

Opening the link asks and changes nothing. A mail scanner or a link preview opens a mailed link before the person does, so a visit is only a page with one button, however often it happens, and ada's address is still unverified afterwards:

KEY=mYmuqAkbwkCAy6zWt9zgSno2Vb_RP4ODRdqKNUC4hm4
for i in 1 2; do
curl -sS -o /dev/null -w '%{http_code}\n' \
"http://localhost:3199/tenants/demo/login-actions/action-token?key=$KEY"
done
docker compose exec -T postgres psql -U odudu -d odudu -At -c \
"select u.email_verified from users u join tenants t on t.id = u.tenant_id
where t.name = 'demo' and u.username = 'ada';"
200
200
f

The button posts the key back, and that verifies it. Pressed a second time, it is refused:

for i in 1 2; do
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST http://localhost:3199/tenants/demo/login-actions/action-token \
--data-urlencode "key=$KEY"
done
docker compose exec -T postgres psql -U odudu -d odudu -At -c \
"select u.email_verified from users u join tenants t on t.id = u.tenant_id
where t.name = 'demo' and u.username = 'ada';"
200
400
t

and a fresh ID token for ada now carries "email_verified": true — nothing else about the token changes, since email and its verification status are the only claims this touches:

{ "email": "ada@example.com", "email_verified": true }

The second press was refused because the link was minted for one redemption, and action_tokens.consumed_at is now set.

The same 400 answers a key that never existed, one presented to the wrong tenant, one past its 12-hour lifespan, or one whose address the user has since changed — consumption compares the token's stored email against the user's current one and refuses on any mismatch, so a verification cannot outlive the address it was proving. None of those four are told apart in the response: the page a browser lands on has no way to use the difference, and telling a prober "this exact key existed" is a smaller leak than it looks, but not one worth taking for free.

--send-verification-email needs --email, and needs the named user to already exist — seeded in this same run or a previous one — or the command refuses with seed_invalid_options before touching the database.

Every ada token or UserInfo response above this section was recorded before this run — that is why they read false and this section's own capture reads true: the account whose Bootstrap this document shares was verified here, not earlier.