Reported as "GET works, PUT/POST to add indexers fail, reloading never helps,
and I am never re-prompted". Diagnosis lands here; the MANIFEST FIX DOES NOT,
deliberately -- see the blocker below.
ROOT CAUSE, measured from two pods' logs interleaved to the second:
15:31:06 jackett [AuthSuccess]
15:31:29 radarr "cookie signature not valid, removing session"
15:31:32 jackett "No valid authentication in request" <- 3s later
Six oauth2-proxy instances (seven pods) emit the DEFAULT cookie name
`_oauth2_proxy` on `.home.keithjackson.dev` holding seven DIFFERENT
cookie-secrets (compared in-cluster by sha256; all distinct). `removing
session` is ClearSession, which expires the cookie on radarr's own zone-wide
--cookie-domain, so jackett's session is DELETED by a process in another
namespace, 26s after a clean login whose `expires` was 5 minutes out. 8 such
lines in radarr's log, 1 in jackett's, 0 elsewhere. It was never expiry and it
was never Jackett -- and it is why reloading does not help: the operator's
Radarr tab re-clears the cookie seconds after each new login.
Two contributing mechanisms, also measured:
* session lifetime == Keycloak access-token lifetime, 300.000s exactly, with
refresh_token:true on the same line and --cookie-refresh unset (default 0).
* XHRs answered 302 not 401: oauth2-proxy's AJAX guard needs an exact
`Accept: application/json`, which Jackett's jQuery does not send. On /api/
paths: 33 x 302 vs 2 x 401. A browser following a 302 re-issues a POST as a
GET and DROPS THE BODY -- 4 of 6 POSTs never reached Jackett at all.
WHY NO MANIFEST FIX HERE. apps/wave-0/jackett.yaml is a GENERATED GOLDEN. The
sidecar args come from one shared function, _oauth2_sidecar_container() in
catalog/render.py; catalog/jackett.yaml declares only auth.mode/auth.groups and
the auth schema is additionalProperties:false, so there is NO per-app override
hook. Hand-editing the rendered file is rejected by the gate exactly as
designed ("GOLDEN DRIFT ... render-golden: FAIL"). Changing the renderer
instead rewrites all five rendered apps -- measured:
CHANGED: apps/wave-0/{jackett,pihole,qbittorrent}.yaml
CHANGED: apps/wave-1/{radarr,sonarr}.yaml
and render-golden requires all five be regenerated in the same commit. Four of
them are owned by fix/oauth2-proxy-cookie-collision-fleet right now. The cookie
fix is ONE change, not five; it needs re-cutting as a single renderer commit
rather than two branches rewriting the same generated block.
Validated the intended argument list against the pinned image and the real
issuer before stopping: it printed back `name:_oauth2_proxy_jackett ...
domains:jackett.home.keithjackson.dev ... refresh:after 2m30s` and
`API route - Path: ^/api/`. Two corrections fell out of that: --api-route is
SINGULAR (--api-routes does not exist and the proxy refuses to start), and
--session-cookie-minimal is mutually exclusive with --cookie-refresh
("cookie_refresh > 0 requires oauth tokens in sessions"), so dropping
--pass-access-token does NOT shrink the cookie -- it is a least-privilege
change, not a size one.
Adds runbooks/oauth2-proxy-session.md: the four sidecar log lines that tell the
auth layers apart (including the upstream `-` that means the proxy answered a
request the app never saw), the cross-log collision diagnosis, and the
generated-golden warning with its blast radius. Registered in the
configMapGenerator AND runbooks/README.md so the URL resolves.
Jackett is NOT fixed by this commit and will keep losing its session to Radarr
until the renderer change ships.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
11 KiB
oauth2-proxy-session — telling the auth layers apart from the sidecar log
Not alert-driven. This is an operator procedure for the per-app oauth2-proxy sidecars
(jackett, pihole, qbittorrent, radarr, sonarr, crafty) and the shared forward-auth
proxy in namespace oauth2-proxy. Use it when a gated UI behaves as though you are signed in and
signed out at the same time: the page loads, but its buttons and saves quietly do nothing.
The sidecar log contains email addresses, session UUIDs and full callback URLs including an OAuth
code. Read it, do not paste it. (Repo rule §6: secrets never enter a transcript.)
What this means here
Every gated app on home.keithjackson.dev has at least two independent authentications
stacked on it, and the symptom rarely names the one that refused:
- Keycloak — the
homelabrealm issues the token. Failures here are visible: you see a login form, or an error page from Keycloak's own hostname. - The oauth2-proxy sidecar — validates the session cookie and the group claim
(
media-users/platform-admins). This is the layer that fails silently, and the rest of this page is about reading it. - The app's own login, where it has one — Crafty and Radarr/Sonarr each present a local login
behind the Keycloak gate. See
runbooks/crafty-admin-credential.mdfor that layer.
The sidecar's access log distinguishes all three, if you know the four lines to look for. Get it:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n jackett logs \
-l app.kubernetes.io/name=jackett -c oauth2-proxy --tail=-1'
Substitute the namespace and label for the app you are chasing. Always pass --context k3s-prod:
dev.sh merges both clusters and picks a current-context per invocation, and that default has
already changed once. Do not run two dev.sh invocations at the same time.
The four lines, and what each one means
| Log line | What actually happened | Which layer |
|---|---|---|
[AuthSuccess] Authenticated via OAuth2: Session{... groups:[...]} |
Keycloak issued a token and the proxy accepted it. The IdP is fine. | Keycloak ✔ |
[oauthproxy.go:1026] No valid authentication in request. Initiating login. |
No session cookie was presented at all. Either you never had one, or something deleted it — see below. | sidecar |
[stored_session.go:94] Error loading cookied session: cookie signature not valid, removing session |
A cookie of the right name arrived that this proxy could not decrypt. Almost always a different proxy's cookie. Note removing session: this proxy then deletes it. |
sidecar, cookie collision |
an upstream path of - instead of / in an access-log line |
the proxy answered the request itself; it never reached the app. Compare POST - "/api/..." against POST / "/api/...". |
sidecar |
That last column is the one people miss. The upstream field sits between the method and the quoted
path. - means the proxy handled it (a redirect, a 401, its own /oauth2/* endpoints); / means
it was proxied to the app. Count them:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n jackett logs \
-l app.kubernetes.io/name=jackett -c oauth2-proxy --tail=-1 \
| grep -v "\"/ping\"" | grep -E " (POST|PUT|DELETE) " | cut -c1-140'
If your writes show -, the app never saw them. On 2026-09-30, 4 of the 6 POSTs in jackett's
log had -.
First commands
1. Did Keycloak ever succeed? No AuthSuccess at all means this is not a cookie problem —
go to runbooks/keycloak-down.md or runbooks/keycloak-client-secrets.md.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n <ns> logs \
-l app.kubernetes.io/name=<app> -c oauth2-proxy --tail=-1 | grep -c AuthSuccess'
2. How long does a session last? Subtract created from expires on an AuthSuccess line.
If it is exactly 5m, the session's lifetime is Keycloak's access-token lifetime and the proxy
is not refreshing it:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n <ns> logs \
-l app.kubernetes.io/name=<app> -c oauth2-proxy --tail=-1 \
| grep AuthSuccess | tail -1 | grep -o "created:[^ ]* [^ ]*\|expires:[^ ]* [^ ]*"'
The same line says refresh_token:true — the proxy is holding a refresh token. It will only
spend it if --cookie-refresh is set; the default is 0, meaning disabled.
3. Is this a cookie collision? Two checks, and you want both.
Names and scopes — every instance that emits the default name _oauth2_proxy on the shared
.home.keithjackson.dev domain is in the collision set with every other one:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pods -A -o json \
| grep -E "cookie-name|cookie-domain"'
An instance with no --cookie-name argument is using the default. That is the failure — the
flag being absent, not wrong.
The smoking gun — search every proxy, not just the one you are debugging, for the removal line, and put the timestamps next to each other:
infra/devcontainer/dev.sh run bash -c 'for ns in jackett pihole qbittorrent radarr sonarr crafty; do \
echo "== $ns"; kubectl --context k3s-prod -n $ns logs -l app.kubernetes.io/name=$ns \
-c oauth2-proxy --tail=-1 2>/dev/null | grep stored_session; done'
4. Do the cookie-secrets actually differ? They should — but confirm the collision is between instances that genuinely cannot read each other. Compare hashes, never values:
infra/devcontainer/dev.sh run bash -c 'for ns in jackett pihole qbittorrent radarr sonarr crafty; do \
printf "%s " "$ns"; kubectl --context k3s-prod -n $ns get secret $ns-oauth2-proxy \
-o jsonpath="{.data.cookie-secret}" | sha256sum | cut -c1-12; done'
Never print .data or .stringData itself. A password has been leaked into a transcript here
before.
Likely causes, most likely first
- Cookie collision between two per-app proxies. Several instances emit the default
_oauth2_proxyon.home.keithjackson.devwith differentcookie-secrets. Whoever authenticates last overwrites the rest; the losers logcookie signature not valid, removing sessionand — becauseremoving sessionclears the cookie on their--cookie-domain, which is the whole zone — delete a session belonging to an app in another namespace. The victim then logsNo valid authenticationwith no cookie at all, seconds after a clean login, while its ownexpiresis still minutes away. Read both logs side by side; one app's log alone makes this look like random session loss. Fix: a unique--cookie-nameand a host-scoped--cookie-domainon the per-app sidecar. The shared forward-auth proxy keeps the zone-wide scope — cross-host auth is its job. - Session pinned to a 5-minute access token with no refresh.
--cookie-refreshunset means the proxy never spends the refresh token it holds, so the session expires with the access token. Set it to about half the measured token lifetime. - An XHR got a 302 instead of a 401. The app's JavaScript follows the redirect invisibly, the
live Keycloak SSO session satisfies it without a prompt, and the XHR resolves with HTML where
it expected JSON. Nothing is logged as an error and the operator is never re-prompted — this
is what "it just doesn't save, and reloading doesn't help" looks like. Worse on a write: a
browser following a 302 re-issues a POST as a GET and drops the body, so the change is
discarded silently. oauth2-proxy's built-in AJAX guard only fires on an exact
Accept: application/json, which most app JavaScript does not send; make it deterministic with--api-route=<regex>(singular —--api-routesdoes not exist and the proxy refuses to start). Keep browser-navigable paths out of the regex so a plain page load still signs you in. - Group membership. An
AuthSuccessfollowed by403is the--allowed-groupgate, not a session problem. Check thegroups:[...]field on theAuthSuccessline. - The app's own second login. Crafty, Radarr and Sonarr each have one.
AuthSuccessfollowed by the app's own/loginpage is layer 3, not layer 2.
When to escalate or stop
-
Stop before you
kubectl edit/patch/applyanything. Flux reconcilesmain; a hand-applied flag is drift that the next reconcile reverts or, worse, adopts. Argument changes go through a branch and a PR. Reading logs is always safe; changing the Deployment is not an agent action. -
Do not hand-edit
apps/**/<app>.yaml— those files are GENERATED. The five per-app sidecars (jackett, pihole, qbittorrent, radarr, sonarr) get their argument list from one shared function,_oauth2_sidecar_container()incatalog/render.py;catalog/<app>.yamldeclares onlyauth.modeandauth.groups, and theauthschema isadditionalProperties: false, so there is no per-app override hook. A hand-edit to the rendered file is caught by the merge gate'srender-goldenstage ("GOLDEN DRIFT ... does not match the canonical render") and would be erased by the next render anyway. Change the renderer, then regenerate:python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automationBudget for the blast radius: because the function is shared, any change to it rewrites the sidecar block in all five rendered apps, and the gate requires all five be regenerated in the same commit. There is no way to change one app's sidecar arguments alone. Measured 2026-09-30. Coordinate before starting — this is a single change, not five.
-
Crafty is the exception and is not rendered from the catalog: its sidecar is hand-written in
platform/crafty/deployment.yaml, so it can be, and was, fixed independently. -
Validate an argument change before you open the PR, not after. The pinned image will tell you what it understood, without touching the cluster:
podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --helpRunning it with your full proposed argument list prints a
Cookie settings:line and anAPI route - Path:line back at you, and rejects mutually exclusive flags outright (--session-cookie-minimalwith--cookie-refresh, for one). A flag typo here is a CrashLoop on a service you have just told someone is fixed. -
Do not rotate a
cookie-secretto "clear" a bad session. It signs every live session out of that app and does nothing about the collision, which is caused by the names matching, not the secrets differing. -
Escalate to Keycloak (
runbooks/keycloak-down.md,runbooks/keycloak-client-secrets.md) if there is noAuthSuccessin any proxy's log — that is an IdP or client-secret problem and no cookie flag will help. -
Hand off rather than guess if you see
cookie signature not validon a proxy that already has a unique--cookie-name. That combination should not occur; it means something is writing that name which is not in this inventory. Record what you measured.