Skip to main content

CORS: the preflight and the request differ

A browser single-page client is the reader ## Path A above walks through, and until this section landed its preflight failed: /token and /userinfo sent no Access-Control-Allow-Origin at all, so the browser never let the page see the response. A preflight (OPTIONS) carries no client identity — no body, no Authorization header, only Origin, Access-Control-Request-Method and Access-Control-Request-Headers — so it is answered from the tenant's union of every client's registered web_origins. The real request that follows is answered from that one client's own origins, resolved once the request names which client it is: client_id in the form body at /token, the client_id claim of the bearer token at /userinfo. An origin the preflight allowed because it belongs to a different client in the same tenant is, correctly, withheld on the real request — no status code or body changes, the header is just absent, and the browser discards the response on its own.

The tenant's union is one index probe, not a read of every client: the server keeps a client_origins row for each web_origins entry (and each redirect_uris origin, where + asks for them) whenever it writes those lists, with the same normaliser the request side uses, so the preflight asks whether one origin is registered and a tenant with ten thousand clients pays what a tenant with one does. A list edited in SQL empties that client's rows, so the origin is refused until the client is amended through the server (PATCH, or seed client --web-origin), never left allowed after it was removed.

/certs and /.well-known/openid-configuration are unauthenticated public documents: every origin gets Access-Control-Allow-Origin: * and no Vary. /authorize and /login-actions/* are top-level navigations and get no CORS treatment of any kind — a header there would hand a script read access to the login page.

The origins are registered through the server, which is what keeps the preflight's table in step with them: demo-spa and other-app with odudu seed client --public --web-origin, and spa-with-user, created by the bootstrap seed form that carries no such flag, with a PATCH of its web_origins through the admin API. (An UPDATE in psql would empty a client's origins, not add one.) The tenant is cors-demo, on a stack of its own listening on port 3092, so the URLs below say localhost:3092; its clients are demo-spa at https://demo-spa.example, other-app at https://other-app.example, and spa-with-user — also at https://demo-spa.example — carrying the user ada that signs in below. Headers that differ on every run (x-request-id, date, connection, keep-alive) are left out of each response.

odudu seed tenant --name cors-demo
odudu seed client --tenant cors-demo --client-id demo-spa --public \
--redirect-uri http://localhost:8080/callback --web-origin https://demo-spa.example
odudu seed client --tenant cors-demo --client-id other-app --public \
--redirect-uri http://localhost:8080/callback --web-origin https://other-app.example
odudu seed --tenant cors-demo --client spa-with-user \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com
curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "If-Match: $ETAG" \
-H 'content-type: application/json' -d '{"web_origins":["https://demo-spa.example"]}' \
http://localhost:3092/admin/tenants/cors-demo/clients/$SPA_WITH_USER_ID

What that left, read with psql (the precondition of every answer below):

psql -c "select c.client_id, o.origin from client_origins o join clients c on c.id = o.client_id order by 1"
client_id | origin
---------------+---------------------------
demo-spa | https://demo-spa.example
other-app | https://other-app.example
spa-with-user | https://demo-spa.example
(3 rows)

A preflight for /token, from an origin that belongs to other-app, not to the client the real request below will name:

curl -sS -D - -o /dev/null -X OPTIONS \
http://localhost:3092/tenants/cors-demo/protocol/openid-connect/token \
-H "Origin: https://other-app.example" \
-H "Access-Control-Request-Method: POST"
HTTP/1.1 204 No Content
vary: Origin
access-control-allow-origin: https://other-app.example
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-headers: authorization, content-type
access-control-max-age: 600
content-length: 0

The real request, same origin, naming demo-spa instead — whose own origins do not include other-app.example — refused a bad refresh token exactly as it would with no Origin header at all, and the CORS header is simply absent from a response that is otherwise unchanged:

curl -sS -D - -o /dev/null -X POST \
http://localhost:3197/tenants/cors-demo/protocol/openid-connect/token \
-H "Origin: https://other-app.example" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "grant_type=refresh_token&refresh_token=bogus&client_id=demo-spa" \
| tr -d '\r' | grep -vE '^(x-request-id|Date|Connection|Keep-Alive):|^$'
HTTP/1.1 400 Bad Request
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 79

(access-control-allow-origin does not appear in that response at all — vary: Origin does, on every response from this endpoint, allowed or not, so a shared cache never serves one origin's answer to another.) The same request with Origin: https://demo-spa.example — demo-spa's own origin — gets the header back, still a 400 for the same bogus refresh token:

curl -sS -D - -o /dev/null -X POST \
http://localhost:3197/tenants/cors-demo/protocol/openid-connect/token \
-H "Origin: https://demo-spa.example" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "grant_type=refresh_token&refresh_token=bogus&client_id=demo-spa" \
| tr -d '\r' | grep -vE '^(x-request-id|Date|Connection|Keep-Alive):|^$'
HTTP/1.1 400 Bad Request
vary: Origin
access-control-allow-origin: https://demo-spa.example
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 79

/userinfo takes the same split from the other side: the client comes from the access token's client_id claim rather than a body parameter. Signing in as spa-with-user and calling /userinfo with its own origin gets the header; with other-app's origin, the same 200 with the same claims, and no header:

curl -sS -D - -o /dev/null \
http://localhost:3092/tenants/cors-demo/protocol/openid-connect/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Origin: https://demo-spa.example"
curl -sS -D - -o /dev/null \
http://localhost:3092/tenants/cors-demo/protocol/openid-connect/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Origin: https://other-app.example"
HTTP/1.1 200 OK
vary: Origin
access-control-allow-origin: https://demo-spa.example
content-type: application/json; charset=utf-8
content-length: 135
HTTP/1.1 200 OK
vary: Origin
content-type: application/json; charset=utf-8
content-length: 135

The two public documents, from an origin nothing registered:

curl -sS -D - -o /dev/null \
http://localhost:3092/tenants/cors-demo/protocol/openid-connect/certs \
-H "Origin: https://anything.example"
curl -sS -D - -o /dev/null \
http://localhost:3092/tenants/cors-demo/.well-known/openid-configuration \
-H "Origin: https://anything.example"
HTTP/1.1 200 OK
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 455
HTTP/1.1 200 OK
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 2300

(no Vary on either — a fixed wildcard has nothing to vary the response on). And /authorize, which gets nothing:

curl -sS -D - -o /dev/null -X OPTIONS \
http://localhost:3092/tenants/cors-demo/protocol/openid-connect/auth \
-H "Origin: https://other-app.example" \
-H "Access-Control-Request-Method: GET"
HTTP/1.1 404 Not Found
vary: Origin
content-type: application/problem+json; charset=utf-8

No access-control-allow-origin — the browser gets nothing to read this response with. The Vary: Origin here is a side effect of @fastify/cors answering every unmatched OPTIONS request from one global fallback route regardless of which encapsulated scope registered it; it is harmless — nothing downstream keys a cache entry on it — but it is not hidden here as something it is not.