fix(security): promote auth_token cookie to Authorization header (#520) #521
Reference in New Issue
Block a user
Delete Branch "fix/issue-520-cookie-to-authorization-filter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #520. Without this, every browser-side
/api/*call in production hits the backend without auth, gets401 WWW-Authenticate: Basic, and the browser pops a native auth dialog over the running SPA. Two SSE endpoints (/api/notifications/stream,/api/ocr/jobs/.../progress) are particularly visible — the popup fires immediately after login.Fix
AuthTokenCookieFilter(Ordered.HIGHEST_PRECEDENCE, runs before Spring Security):auth_tokencookie.Authorizationheader.Authorizationalready present.After this, the backend behaves identically whether the auth comes from SSR (
hooks.server.tssetsAuthorizationexplicitly) or browser-direct (the filter promotes the cookie). No Caddy changes, no client code changes.Tests
5 cases in
AuthTokenCookieFilterTest:Basic%20YWR...%3D%3D→Basic YWR...=)Authorizationwins, cookie ignoredauth_tokenvalue → pass-throughAlso fixes an unrelated test breakage:
ThumbnailServiceIntegrationTestwas the only@SpringBootTestin the suite without@ActiveProfiles. After #516 madeUserDataInitializerfail-closed outside dev/test/e2e, that test was failing in CI. One-line annotation restores green main.Test plan after merge
https://staging.raddatz.cloud/login, navigate to /, observe no Basic-auth popup.fetchshould return data, not 401./api/notifications/streamreturns 200 withtext/event-stream.🤖 Generated with Claude Code
Nora "NullX" Steiner — Application Security Engineer
Verdict: Changes requested
This is a security-sensitive PR (it changes how the backend ingests auth credentials), so I went over it slowly. The core mechanism is sound — promoting an HttpOnly cookie into the
Authorizationheader is a well-known pattern — but several assumptions that the original architecture depended on no longer hold once browser-direct API calls authenticate via cookie. Those need to be reconciled before this merges.Blockers
CSRF threat model is now broken —
SecurityConfigcomment is stale and wrongSecurityConfig.javadisables CSRF with this justification:That argument no longer holds for browser-direct
/api/*calls. A third-party page can issue a cross-originfetch('https://prod/api/documents/123', { method: 'DELETE', credentials: 'include' }). WithSameSite=strictonauth_tokenthis is currently blocked by the browser — butSameSite=strictis now the only CSRF defence, and the code comment says the opposite. Two things must happen:SecurityConfigcomment to state explicitly that CSRF is prevented bySameSite=stricton theauth_tokencookie, with a reference tofrontend/src/routes/login/+page.server.ts.AuthTokenCookieFilteritself naming the same threat-model dependency.Otherwise the next person to "fix" the login cookie to
SameSite=lax(because strict breaks something) silently opens the application to CSRF on every write endpoint. This is exactly the kind of cross-file invariant that gets broken without a comment pinning it down.secure: falseon the auth cookie in productionfrontend/src/routes/login/+page.server.tsline ~37:Pre-this-PR the cookie was only read by SSR — its security setting didn't really gate API auth. After this PR the cookie is the API credential for every browser-direct call. Shipping it over plaintext HTTP in any environment now means we're leaking a 24-hour Basic-auth token to every passive listener on the network. The deploy target is
staging.raddatz.cloudover HTTPS, so this needs to flip tosecure: env.NODE_ENV === 'production'(or read from a dedicated env var) before this PR goes to prod. Track as a separate fix if you don't want to scope-creep this PR, but the merge should not happen without that fix landing on the same release.Log leakage — wrapped request makes
Authorizationvisible in every access log / audit trail that prints headersThe wrapper exposes
AuthorizationviagetHeaderNames()andgetHeaders(). If anything downstream — Spring'sCommonsRequestLoggingFilter, an MDC enricher, a future audit interceptor, or even a debug breakpoint — iterates headers, the Basic token (= base64(email:password), trivially reversible) lands in logs. Confirm two things:grep -r CommonsRequestLoggingFilter,grep -r logRequestDetails).AuthorizationMUST never be logged. A small redaction filter (HeaderRedactingLogFilterthat stripsAuthorizationandCookiefrom any structured log event) would be the defence-in-depth move. Not necessarily for this PR, but file a follow-up.Suggestions
Scope the filter to
/api/**instead of every requestCurrently the filter runs on
/,/_app/immutable/...,/favicon.ico, and/api/*. Promoting an auth header on requests that don't need one is wasted work and a small but non-zero attack surface — any future filter that inspectsAuthorizationon a static-asset request now sees one. OverrideshouldNotFilter()to return true for non-/apipaths, or guard withrequest.getRequestURI().startsWith("/api/")at the top ofdoFilterInternal. Cleaner intent, smaller blast radius.URLDecoder.decodeswallows the encoding contract — make it explicitThe login action stores
Basic <base64>(with a literal space) and SvelteKit URL-encodes the cookie value toBasic%20<base64>. The filter then URL-decodes. This pas-de-deux is correct but invisible — it's a coupling betweenfrontend/src/routes/login/+page.server.tsandAuthTokenCookieFilterthat lives only in commit history. Add a one-line Javadoc onAuthTokenCookieFilterpointing at the login action filename, and a matching one-line comment in the login action pointing at the filter. Future-me will be grateful.URLDecoder.decodeon attacker-controlled input — confirm no surprise behaviourURLDecoder.decodewill turn+into a space. A malformed cookie likeBasic%20+evilbecomesBasic evil(two spaces). Spring'sBasicAuthenticationFilterwill reject it as malformed, so this is safe today, but I'd add one negative test: cookie value"not-valid-base64-%ZZ"should NOT throw, the filter should pass it through and let Spring return 401.URLDecoder.decodeactually does throwIllegalArgumentExceptionon malformed%sequences in strict mode — verify with a unit test, and if it does throw, wrap in a try/catch that logs and falls through.isEmpty()check is weaker thanisBlank()c.getValue() != null && !c.getValue().isEmpty()lets a cookie likeauth_token=(three spaces) reach the URL decoder and produceAuthorization:, which Spring rejects but only after going down its parsing path. UseisBlank().Test coverage gap — no test for the explicit-
Authorization-wins case at the wrapper boundaryThe test verifies
getHeader("Authorization")returns the explicit value, but doesn't verifygetHeaderNames()andgetHeaders("Authorization")are consistent when the wrapper is bypassed (the early-return path doesn't wrap). That's fine because no wrapping happens, but add an explicit assertion that the request reaching the chain is the same instance as the input (assertThat(captor.getValue()).isSameAs(req)) so we catch a future refactor that silently always-wraps.What I checked and approve of
Ordered.HIGHEST_PRECEDENCEis correct — runs beforeBasicAuthenticationFilter.OncePerRequestFilteris the right base class — no double-promotion on forwards/includes.Authorizationvia all three accessors (getHeader,getHeaders,getHeaderNames) — Spring'sBasicAuthenticationFilterusesgetHeader(AUTHORIZATION)so this works, but a defensively-written downstream filter that iteratesgetHeaderNames()will also see it. Good.HttpOnlyflag on the cookie remains the right call — keeps it out of JS reach, which is the whole point of using a cookie instead oflocalStorage.EventSourcecan't set custom headers, so the cookie path is genuinely the only option here. This PR is the right fix.Address blockers 1, 2, 3 and I'll re-review. Suggestions 4–8 can land as follow-ups if you prefer to keep this PR tight.
— Nora "NullX" Steiner
Markus Keller — Senior Application Architect
Verdict: Approved with concerns
The shape of the fix is right. We had a hidden environmental coupling — dev (Vite proxy) silently translated the auth cookie into a header, prod (Caddy) didn't — and the only correct place to resolve that gap is in the backend itself, where the contract is owned. Moving the translation from infrastructure into the application removes the "works on my machine" class of bug for good. I prefer this over Caddy-side header injection.
That said, this PR introduces a load-bearing invariant that lives implicitly across three files (
AuthTokenCookieFilter,SecurityConfig,login/+page.server.ts), and the project has no ADR explaining why. That's the architectural debt I want addressed before merge.Blockers
This PR enshrines a non-trivial architectural decision: the backend now has two equally-supported ways to authenticate a request (explicit header + promoted cookie), and the choice between them is determined by environment (SSR vs browser-direct). That's a permanent property of the system from now on. Add
docs/adr/00NN-auth-cookie-promotion.mdcovering:Authorizationexplicitly; browser-direct can't becauseEventSourceand manyfetchcall sites don't.header_up(no URL-decode), reformatted cookie (high blast radius), session-based auth (out of scope for this fix).SameSite=strictrather than "custom header impossible from cross-origin" — explicitly link toSecurityConfigcomment.This isn't bureaucracy. The next dev to touch auth will be reading three files trying to figure out which is the source of truth; an ADR collapses that to one read.
Suggestions
Package placement is right but the class is doing two jobs
AuthTokenCookieFiltercontains both the filter andAuthHeaderRequestwrapper as a static nested class. That's fine for now — they're cohesive — but if any other filter ever needs to override headers (rate-limiting based on a forwarded header, tenant routing, etc.), extractAuthHeaderRequestinto a genericHeaderOverridingRequestWrapper. Track as a follow-up; don't pre-extract.Cross-file invariant — link the three load-bearing comments
frontend/src/routes/login/+page.server.ts: "stores URL-encoded Basic header in cookie"AuthTokenCookieFilter: "decodes cookie, promotes to header"SecurityConfig: "CSRF safe because [something]"These three must change together or the system breaks subtly. The Javadoc on
AuthTokenCookieFilteralready points at the SvelteKit login action (good), but the login action andSecurityConfighave no reciprocal pointer. Add two-line comments that name the other files. We don't have a linter for this — comments are our linter.Filter scope: consider
/api/**onlyNora flagged this from a security angle. Architecturally I agree for a different reason: domain boundary clarity. The filter belongs to the API auth pipeline, not the page-serving pipeline. Make that explicit with
shouldNotFilter()returning true for non-/apipaths. Reduces cognitive load when reading filter ordering.One file, one decision — the drive-by is acceptable here, but flag the pattern
@ActiveProfiles("test")onThumbnailServiceIntegrationTestis a fix for a real CI failure introduced by #516 fail-closingUserDataInitializer. The PR body calls it out, so I'm not asking you to split the commit. But if this happens twice more, we have a pattern problem — we need a test-base-class or a meta-annotation@SpringBootIntegrationTestthat bundles@SpringBootTest + @ActiveProfiles("test") + @Import(PostgresContainerConfig.class). File a follow-up.What I approve of
Ordered.HIGHEST_PRECEDENCEis the right precedence — security filters compose downstream.Address the ADR and I'll mark this approved.
— Markus Keller
Felix Brandt — Senior Fullstack Developer
Verdict: Approved with concerns
Clean implementation, tests-first evidence in the test class, good Javadoc on the filter explaining why (not what). The two minor refactors I'd ask for are about consistency with the rest of the codebase, not correctness.
Suggestions
super.getHeaderNames()null-handlingServlet spec allows
getHeaderNames()to returnnullif the container doesn't allow introspection. Jetty doesn't do that, but defensive code costs nothing:if (base != null) { while (...) }. The tests useMockHttpServletRequestwhich always returns a non-null enumeration, so this path is uncovered.Inline
java.util.LinkedHashSetimport is uglyEither import
java.util.Setandjava.util.LinkedHashSetat the top, or useCollections.list(base)(returns anArrayList) and add the header to it. Inline FQNs read as "I forgot to organize imports". Small but the rest of this file is clean — make this match.Constant for cookie name — already done, good
static final String COOKIE_NAME = "auth_token";is package-private so the test can reference it. Today the test uses the raw string"auth_token"— switch the test toAuthTokenCookieFilter.COOKIE_NAMEso a future rename can't desync them.Test class — extract common setup
Every test does the same four lines:
Pull
resandchaininto@BeforeEach-initialized fields, or — better — extract arunFilter(MockHttpServletRequest)helper that returns the captured forwarded request. Test bodies then read as one-line behaviour assertions. Right now the AAA structure is there but the noise drowns it out.Test name on test 4 —
passes_through_when_auth_token_cookie_is_absentReads well. But the assertion
verify(chain).doFilter(req, res)checks the unwrapped request was forwarded — which is the right behaviour but the test doesn't say so in its name. Either rename topasses_through_request_unwrapped_when_auth_token_cookie_is_absent, or accept that "passes through" is sufficient project-vocab. I'd rename.What I like
promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_headeris the kind of test name I want everywhere.@ActiveProfiles("test")is correctly called out in the PR body. Atomic-commit purists might want it split, but the PR body is honest and the fix is one line — keeping it here is fine.No blockers from me. Address the suggestions or open a follow-up — either way I'm green.
— Felix Brandt
Sara Holt — Senior QA Engineer
Verdict: Approved with concerns
Five unit tests for a five-branch filter — good ratio, good naming, good AAA structure. What's missing is the integration test that proves this works with Spring Security. A unit test confirming the wrapper exposes
Authorizationis necessary but not sufficient; what we actually care about is: "doesBasicAuthenticationFiltersee the promoted header and authenticate?" That needs a real Spring context.Blockers
Add a
@SpringBootTest(or@WebMvcTestwith@Import(SecurityConfig.class)if you want it lightweight) that:/api/users/me)Cookie: auth_token=Basic%20<base64(test@user:test123)>header — noAuthorizationheaderAnd a paired test:
Cookie: auth_token=Basic%20<garbage>Without this, the unit tests prove "the wrapper works in isolation" but not "the wrapper integrates with
BasicAuthenticationFilter". Given the production bug we're fixing, the integration test is the test that gives us the most confidence on the next deploy.Suggestions
Negative tests on URL-decoder edge cases
URLDecoder.decodecan throwIllegalArgumentExceptionon malformed%sequences (e.g.Basic%2). Add a test:If this currently throws (likely), that's a bug — a malformed cookie would 500 the whole request. We need either a try/catch or a verified test asserting JDK behaviour is what we expect.
Test the wrapper itself in isolation
AuthHeaderRequestis a non-trivial wrapper exposing three methods (getHeader,getHeaders,getHeaderNames). I'd extract those into a tiny test class:getHeader("Authorization")returns override (case-insensitive)getHeader("authorization")also returns overridegetHeaders("Authorization")returns single-element enumerationgetHeaderNames()contains "Authorization" exactly once even if base already had one (today: collected into LinkedHashSet, so duplicate is implicitly dropped — verify)getHeader("Content-Type")returns the base valueThe current tests cover the filter's use of the wrapper but not the wrapper's contract. The wrapper is the part most likely to surprise us downstream — test it directly.
Case sensitivity test missing
Most servers normalize to canonical case, but HTTP headers are case-insensitive per RFC. Test:
The filter's check is
request.getHeader(HttpHeaders.AUTHORIZATION) != null—getHeaderis case-insensitive per servlet spec, so this should work, but make it explicit.No tests for SSE-specific behaviour
The PR body specifically mentions
/api/notifications/streamand/api/ocr/jobs/.../progress. SSE has nothing special at the filter level, but a smoke test against the SSE endpoint in the integration test (verifying it returnstext/event-streamafter auth) would prove the production scenario end-to-end.Drive-by
@ActiveProfiles("test")onThumbnailServiceIntegrationTestThis is the test fix I'd actually like to verify with a CI run on the branch before merge. Confirm the green pipeline on PR head, then merge. Don't trust the local
./mvnw test— the fail-closed behaviour from #516 is profile-sensitive.What I like
promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_headertells me exactly what behaviour is verified without reading the body.ArgumentCaptorusage is correct — verifying the wrapped request reached the chain, not just thatdoFilterwas called.times(1)is over-specified but harmless.verify(chain)defaults to 1, so the explicittimes(1)adds noise without value. Style nit; not blocking.Add the integration test and the malformed-encoding test, and I'm green.
— Sara Holt
Tobias Wendt — DevOps & Platform Engineer
Verdict: Approved
This fix removes a real production failure mode — "auth works in dev because Vite, breaks in prod because Caddy" — by moving the translation into the backend, where it belongs. From an operations standpoint that's exactly the right call: fewer environment-specific knobs, fewer places where staging and prod can diverge silently.
What I checked
HIGHEST_PRECEDENCE: no measurable overhead, no observable behaviour change for unauth'd endpoints. Won't affect/actuator/health(permitAll, no cookie inspection needed, fast path).@ActiveProfiles("test")onThumbnailServiceIntegrationTest: fixes a CI failure introduced by #516. This is exactly the kind of post-merge babysitting that I appreciate — catching the integration test breakage before it became a "main is red, can't deploy" emergency.Suggestions
Add the production smoke-test to the deploy checklist, not just the PR
The PR test plan says "redeploy staging, observe no Basic-auth popup". Promote that to the
docs/DEPLOYMENT.mdpost-deploy checklist as a permanent item, because the failure mode (silent 401 + browser popup) is hard to detect from a health endpoint —/actuator/healthwill be green while every authenticated SSE stream is broken. We need a human-eyes step in the deploy ritual.Synthetic monitor for
/api/notifications/stream?If we ever build a Prometheus blackbox check or uptime monitor, the SSE endpoint should be on it — it's the canary for this entire class of bug. Out of scope for this PR; file a follow-up if you agree.
Caddy access log: confirm Authorization headers are not logged
Nora flagged this for the app side. From the infra side: check
infra/caddy/Caddyfile(or equivalent) for anylogdirective that captures request headers. If Caddy logsCookie:(it doesn't by default, but custom formats can), the Basic-auth token is in our centralized log aggregator for everyone with read access. I'm 90% confident our Caddy config doesn't do this — but a one-linegrep -r 'request_headers' infra/caddy/confirms. Quick win before merge.Rollback story
This is a single-commit, single-file (plus test) addition. Revert is trivial. The only risk on revert is: any browser holding the
auth_tokencookie will be back to the original broken state — but they were already in that state pre-PR, so revert is genuinely safe. Document the revert plan in the PR body if you want to be thorough; otherwise the diff speaks for itself.What I don't worry about
.env.exampledrift between environments.This is the kind of fix I like: small, reversible, removes operational complexity instead of adding it. Ship it after Nora signs off on the cookie-
secureflag.— Tobias Wendt
Elicit — Requirements Engineer & Business Analyst
Verdict: Approved
This is a bug-fix PR with a clear, well-scoped problem statement and an aligned solution. The issue (#520) frames the user-visible symptom precisely — "browser pops a native Basic-auth dialog over the running SPA right after login" — and the fix delivers against that. From a requirements-traceability standpoint, the chain is clean: user-visible behaviour → root cause (Caddy doesn't translate cookie) → fix (backend filter) → acceptance test (no popup after redeploy).
Suggestions
Acceptance criteria in the PR body are good but not testable as-stated
The test plan items are observational ("observe no Basic-auth popup", "DevTools Network tab shows 200"). For a regression suite — i.e. so a future PR can't reintroduce this — promote at least one to an automated check. Suggested wording:
/api/users/mewith only aCookie: auth_token=Basic%20<valid-base64>header (noAuthorizationheader) returns 200 with the user payload./api/notifications/streamwith the same cookie returns 200 withContent-Type: text/event-stream.Authorizationreturns 401, and the response does NOT includeWWW-Authenticate: Basic(to suppress browser popup) — though this is a separate concern, not addressed by this PR.AC3 is interesting: even with the cookie filter, an unauthenticated direct-to-API call still returns
WWW-Authenticate: Basic, which still pops a dialog. Are we sure this PR alone closes the user-visible bug, or do we also need to suppressWWW-Authenticatefor/api/*paths to fully solve the symptom? Worth confirming with a manual test before claiming "fixed".The functional requirement is buried in implementation prose
The PR description leads with the fix. For traceability — and for a non-technical stakeholder reviewing the changelog — flip it: lead with the user-visible requirement ("browser-side fetch and EventSource calls to /api/ must authenticate transparently using the auth_token cookie"), then describe the implementation. Small writing-style point; helps when this PR becomes a reference for the next person debugging auth.
Scope flag — the drive-by
@ActiveProfiles("test")is acceptable but worth a note in the changelogTwo unrelated changes in one PR is something I'd normally flag as scope-creep, but the PR body calls it out, the second change is one line, and CI was red. In requirements language: this PR satisfies two requirements (R1: fix prod auth; R2: restore green CI). Acceptable. Future PRs of similar shape should mention the second requirement in the title prefix (e.g.
fix(security): ... + test: restore green CI for ThumbnailServiceIntegrationTest).Documentation lag —
docs/ARCHITECTURE.mdpermission system section may need an updateThis PR introduces a new authentication path (cookie → header promotion) that wasn't part of the original architecture document. Confirm
docs/ARCHITECTURE.md(or wherever the auth flow is described) gets a sentence-level update describing the cookie-promotion behaviour. Otherwise the next dev reads outdated docs.Out of scope but worth flagging for a follow-up issue
Currently auth is described across:
SecurityConfig.java(Java comments),frontend/src/routes/login/+page.server.ts(TypeScript code),frontend/src/hooks.server.ts(handleFetch),AuthTokenCookieFilter(new). For a solo + LLM workflow, having auth scattered across four files is fine if there's one canonical reference doc. Worth filing as a future issue: "Document auth flow indocs/AUTH.mdor as an ADR".What I approve of
Approved — apply Nora's security blockers as the actual gating, my notes are nice-to-haves for documentation hygiene.
— Elicit (Requirements Engineer)
Leonie Voss — UI/UX Designer & Accessibility Strategist
Verdict: Approved — LGTM (no UI changes)
This is a backend-only PR (one Java filter + one test class + one
@ActiveProfilesannotation). No frontend code, no markup, no styles, no accessibility surface touched.What I checked
frontend/changes in the diff. No Svelte components added/modified, no Tailwind utilities touched, nomessages/{de,en,es}.jsonupdates needed.UX impact (positive, indirect)
The bug this PR fixes was a particularly nasty UX regression: a native browser modal sitting on top of the SPA, blocking interaction, with no relationship to our visual language. For a 67-year-old transcriber on a tablet, that popup is confusing in a way our own error UI never would be — it's unbranded, it asks for credentials in an unfamiliar form, and dismissing it doesn't recover the session. Fixing this restores the experience we actually designed for. Worth a line in the release notes if we publish those: "browser auth dialog no longer interrupts logged-in sessions".
Out of scope but worth flagging for product
The pre-fix failure mode (silent 401 on browser-direct API calls) means real users may have been seeing partially-loaded pages — missing notifications, broken transcription panels — without an obvious error. If we have any user feedback or support tickets that mention "I logged in but nothing loaded", they're probably this bug. Worth a one-line check with the small group of beta transcribers to confirm we caught all the reports.
No blockers, no suggestions. Backend ships when backend reviewers are happy.
— Leonie Voss
- frontend/login: derive cookie `secure` flag from request URL protocol. Pre-PR the cookie was only read by SSR so the flag didn't matter; now the cookie IS the API credential and must be Secure on HTTPS or it leaks a 24h Basic token on plaintext networks. Dev runs over HTTP and would silently lose the cookie if we hardcoded `secure: true`, so the flag follows `event.url.protocol === 'https:'`. - SecurityConfig: rewrite the CSRF-disabled comment. The old "browsers block cross-origin custom headers" justification no longer holds once /api/* is authenticated via the cookie. Make the load-bearing dependencies explicit: SameSite=strict on the auth_token cookie + Spring's default CORS rejection. - AuthTokenCookieFilter: - Scope to /api/* only. /actuator/health and similar must not be cookie-authenticated. - Refuse malformed percent-encoding (URLDecoder throws); forward the request without a promoted Authorization rather than crash. - Use isBlank() instead of isEmpty() per Nora. - Javadoc warning: getHeaderNames/getHeaders exposes the Basic credential; any future header-iterating logger must scrub Authorization before logging. - Tests: add `passes_through_unchanged_when_request_is_outside_api_scope` (/actuator/health with cookie should NOT be wrapped) and `passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding`. Tighten the explicit-header test to verify same-instance forwarding rather than just header equality. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>