A CORS preflight is answered wherever a CORS header is written - #39
Merged
Merged
Conversation
`OPTIONS` matched no route on this server. It fell through to whatever the host had, and a host with a deny-everything fallback policy answered 401 - on the discovery documents, on the JWKS, and on the RFC 9728 metadata the resource server serves. Measured against a running deployment, 2026-09-08. WHY IT IS WORTH A RELEASE RATHER THAN A NOTE, given nothing was broken today: a browser preflights only once a request stops being simple, so the client that finds this is the first one to add a request header of its own. What that client is shown is not a 401. It is the browser's generic "no Access-Control-Allow-Origin header is present", pointing at CORS configuration that is correct - and the cost is the hour spent looking there. Authenticating a preflight cannot be right in any case: the browser sends it with no credentials by specification, so there is nothing in it to authenticate. The rule is one sentence: every route that writes Access-Control-Allow-Origin answers OPTIONS, and no other route does. That is the two discovery documents, the JWKS, the three /.well-known catch-alls, /token, and both forms of the protected-resource metadata. The boundary is held from both sides by tests, because the permissive half is the half somebody would later read as a licence: /authorize MUST have no CORS at all (OAuth 2.1 §3.2, RFC 9700 §2.6), and a change that made every OPTIONS succeed would have taken that with it. Three decisions in it: - The requested headers are ECHOED rather than published as a fixed list. These endpoints are read without credentials and already answer `*` with no Access-Control-Allow-Credentials, so the browser sends no ambient authority and naming back what was asked grants nothing they do not already grant to anyone. A fixed list would make the next header a client adds the next incident, which is how this was found. - The /.well-known catch-alls answer the preflight too, and still 404 after it. That reads backwards until you see the alternative: a refused preflight tells a browser client only "CORS", while an answered one lets the real request reach the bare 404 the route exists to give - the answer that lets a client try its next probe. - 204 and not 200: there is nothing to send, and a preflight body is a body every client discards. DiscoveryEndpoints' remarks said "these are simple cross-origin GETs, so no preflight is involved". That was true of the clients we had rather than of the endpoint, and it is corrected in place with what it cost. Red-first: the three document routes and /token fail without the change, and the control - a host route that never asked for CORS - passes before and after. <Version> 0.5.2 and the baseline to 0.5.1, which the architecture test holds to CHANGELOG's second heading. Nothing a consumer compiles against moved; the version turns because the behaviour did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HwL9CdWHruodcVHeQAKtGR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
OPTIONSmatched no route on this server. It fell through to whatever the host had, and a hostwith a deny-everything fallback policy answered
401- on the discovery documents, on the JWKS,and on the RFC 9728 metadata the resource server serves.
Measured against a running deployment, 2026-09-08:
Why this is worth a release, given nothing was broken that day
A browser preflights only once a request stops being simple, and the OIDC client libraries fetch
these with CORS-safelisted headers alone. So the defect waits for the first client that adds a
request header of its own.
What that client is shown is not a
401. It is the browser's generic "noAccess-Control-Allow-Originheader is present", pointing at CORS configuration that is in factcorrect. The cost is the hour spent looking there. That is how this was found: chasing exactly that
message on a deployment whose CORS was right.
Authenticating a preflight cannot be right in any case. The browser sends it with no credentials by
specification, so there is nothing in it to authenticate.
The rule
Every route that writes
Access-Control-Allow-OriginanswersOPTIONS, and no other routedoes. That is the two discovery documents, the JWKS, the three
/.well-knowncatch-alls,/token, and both forms of the protected-resource metadata.The boundary is held from both sides, because the permissive half is the half somebody would
later read as a licence:
/authorizeMUST have no CORS at all (OAuth 2.1 §3.2, RFC 9700 §2.6), anda change that made every
OPTIONSsucceed would have taken that with it. There is a test for theroute that must not be touched, not only for the ones that must.
Three decisions
The requested headers are echoed, not published as a fixed list. These endpoints are read
without credentials and already answer
*with noAccess-Control-Allow-Credentials, so thebrowser sends no ambient authority and naming back what was asked grants nothing they do not
already grant to anyone. A fixed list would make the next header a client adds the next incident,
which is precisely how this one arrived.
The
/.well-knowncatch-alls answer the preflight too, and still404after it. That readsbackwards until you see the alternative: a refused preflight tells a browser client only "CORS",
while an answered one lets the real request reach the bare
404the route exists to give - theanswer that lets a client try its next probe, which is the whole reason that route is there.
204and not200: there is nothing to send, and a preflight body is a body every clientdiscards.
A comment that had become false
DiscoveryEndpoints' remarks ended "these are simple cross-origin GETs, so no preflight isinvolved". That was true of the clients we had rather than of the endpoint. Corrected in place,
with what it cost.
Red-first
The three document routes and
/tokenfail without the change; the control - a host route thatnever asked for CORS - passes before and after.
Checks
The one red suite is
Boltway.Storage.PostgreSql.Tests, which fails rather than skips without areal server and has none in this container. That is the documented behaviour, not a regression: a
storage suite that skips itself is green in exactly the situation where it measured nothing.
Version
<Version>0.5.1 -> 0.5.2 andPackageValidationBaselineVersion0.5.0 -> 0.5.1, whichThe_api_compatibility_baseline_is_the_previous_released_versionholds to CHANGELOG's secondheading. Nothing a consumer compiles against moved; the version turns because the behaviour did.
Deploying it
No Boltway tag is needed.
AUTH_TAG=mainin the deploy workflow, because this repository has norelease process, so the next connector deploy carries this once
cihas pushed the image.🤖 Generated with Claude Code
https://claude.ai/code/session_01HwL9CdWHruodcVHeQAKtGR
Generated by Claude Code