The Authentik OIDC Redirect Loop: Why Node.js res.setHeader Overwrites Rather Than Appends

· 3 min read · Engineering authentikoidcnodejscookiesauthenticationdocker

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:

  1. Setting the permanent authenticated session cookie (app_sess=<token>; HttpOnly; Secure; SameSite=Lax).
  2. 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:

The entire failure mode was traced and resolved at the HTTP protocol layer without altering identity provider settings or degrading token security.

🛡️ Production Infrastructure & SRE

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.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts