The Authentik OIDC Redirect Loop: Why Node.js res.setHeader Overwrites Rather Than Appends
The infinite login loop with clean logs
The incident presented as a silent access failure. Users attempting to access the protected administration dashboard at /admin were redirected to the Authentik identity provider, prompted for multi-factor credentials, granted consent, and redirected back to the application callback.
Instead of landing on the dashboard, the browser immediately rebounded to Authentik. Authentik recognised the existing active user session, issued a fresh authorization code, and redirected back once again. This ping-pong loop repeated until the browser aborted with ERR_TOO_MANY_REDIRECTS.
Inspecting the service logs inside the container showed no errors whatsoever:
127.0.0.1 - - [14/Feb/2025:08:12:04 +0000] "GET /admin HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:05 +0000] "GET /callback?code=... HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:05 +0000] "GET /admin HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:06 +0000] "GET /callback?code=... HTTP/1.1" 302 -
Every single hop reported HTTP 302 Found. Authentik logged successful token exchanges. The OpenID Connect tokens were mathematically valid and unexpired. Yet the application refused to recognize that authentication had succeeded.
Root cause: how res.setHeader erased sessions
The application runtime used a lightweight native Node.js HTTP server without bulky frameworks. During the OIDC callback handling cycle, two distinct cookie operations needed to happen in sequence:
- Setting the permanent authenticated session cookie (
app_sess=<token>; HttpOnly; Secure; SameSite=Lax). - Expiring and clearing the transient OIDC state cookie (
app_oidc=; Max-Age=0).
The naive response helper had been written like this:
function setCookie(res, cookieStr) {
res.setHeader('Set-Cookie', cookieStr);
}
In the Node.js http module (OutgoingMessage), calling res.setHeader(name, value) with a string argument is an atomic replacement, not an append operation.
When the callback controller executed:
// Step 1: Establish session
setCookie(res, `app_sess=${sessionToken}; Path=/; HttpOnly; Secure; SameSite=Lax`);
// Step 2: Clear transient state
setCookie(res, `app_oidc=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
The second call completely blew away the Set-Cookie header established by the first call.
When inspecting the actual response headers using curl -v:
< HTTP/1.1 302 Found
< Location: /admin
< Set-Cookie: app_oidc=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax
The app_sess cookie never reached the browser. The browser followed the redirect to /admin, sent no session token, failed the route guard, and was promptly dispatched right back to Authentik. Because every step succeeded according to its own isolated logic, not a single line of warning was emitted to stderr.
The fix: array-based header aggregation
The fix required ensuring that any invocation of setCookie preserves previously registered cookies in the response header array.
🔍 server.mjs — setCookie implementation (12 lines)
function setCookie(res, cookieStr) {
const existing = res.getHeader('Set-Cookie');
let cookies = [];
if (Array.isArray(existing)) {
cookies = existing.concat(cookieStr);
} else if (typeof existing === 'string') {
cookies = [existing, cookieStr];
} else {
cookies = [cookieStr];
}
res.setHeader('Set-Cookie', cookies);
}
By coercing existing header values into an array and passing the array back to res.setHeader(), Node’s serialization layer produces multiple distinct Set-Cookie lines in the raw HTTP response.
The result
After deploying the patch, the OIDC handshake was verified using an automated Playwright headless browser script.
The test script completed the full authentication lifecycle:
- Origin request to
/adminintercepted and redirected to Authentik. - Single sign-on form filled and submitted.
- OIDC authorization consent granted.
- Callback returned with both
app_sessand expiredapp_oidccookies present. - Destination page loaded with HTTP 200, successfully rendering all 21 management rows in the admin dashboard.
The entire failure mode was traced and resolved at the HTTP protocol layer without altering identity provider settings or degrading token security.
Dealing with flaky containers, cloud cost bloat, or unexplained outages?
Real production reliability comes from understanding the kernel and the protocol layers, not just restarting containers. I manage 162 containers across 3 hosts running 24/7 client workloads.