DEV Community

Cover image for How a rotating refresh token turned into a 401 storm on Cloudflare Workers
Alex DevOps engineer
Alex DevOps engineer

Posted on

How a rotating refresh token turned into a 401 storm on Cloudflare Workers

We run a Cloudflare Workers + D1 backend with rotating refresh tokens. One afternoon production started sending a steady stream of 401s on POST /v1/auth/refresh — not a spike, a storm: dozens of alerts, spread out, never stopping.

The obvious suspect (a rotation race between two browser tabs sharing one refresh cookie) turned out to only be part of the story. The actual fix took three small, independent changes:

  1. The frontend called refresh on every page load, even with a still-valid access token — turning ordinary browsing into constant unnecessary rotation.
  2. A rejected refresh never cleared the httpOnly cookie, so a browser with a dead session kept replaying the same doomed request forever.
  3. Anonymous visitors (no session at all) were still calling refresh and getting a guaranteed 401 on every view.

None of this needed a new backend mechanism — just removing assumptions that had quietly stopped being true.

The project

This is Brewly Store, a coffee e-commerce platform built entirely on Cloudflare's edge:

  • Backend — a Cloudflare Workers API written in Hono with @hono/zod-openapi, backed by Cloudflare D1 (SQLite at the edge). It owns auth, users and products.
  • Frontend — a Nuxt 4 storefront (Vue 3, Pinia, TailwindCSS) deployed to Cloudflare Pages.
  • Bot — a Telegram bot, also on Workers, sharing the same D1 database.

Auth uses a short-lived access token (in memory on the client) plus a rotating refresh token kept in an httpOnly, cross-origin cookie. Because the cookie is httpOnly and set on a different origin than the storefront, the browser can send it but JavaScript can never read it. Remember that constraint — it's the seed of the whole storm.

The storm

The symptom was a flat, unrelenting line of 401 Unauthorized on POST /v1/auth/refresh. Not correlated with a deploy, not correlated with traffic peaks — just a constant background hum of failed refreshes, each one firing an alert.

Latency and error budgets were fine for real endpoints. The 401s were "successful failures": the Worker was doing exactly what it was told, rejecting refresh requests that could never succeed. The bug wasn't in the rejection — it was in who kept asking.

Fix 1 — stop refreshing on every page load

The Pinia auth store refreshed the session on initialization, unconditionally. Every navigation that re-hydrated the store fired a refresh, even when the access token had 40 minutes of life left. On a rotating-token scheme that's not just wasteful — every refresh rotates the token, so two quick navigations could race each other.

The fix is a threshold: only refresh when the access token is actually near expiry.

const REFRESH_THRESHOLD_MS = 60_000

function needsAccessTokenRefresh() {
  if (!expiresAt.value) return true
  return expiresAt.value - Date.now() <= REFRESH_THRESHOLD_MS
}
Enter fullscreen mode Exit fullscreen mode

Now ordinary browsing rides the existing access token and only refreshes in the last minute of its lifetime, instead of on every mount.

Fix 2 — clear the dead cookie when refresh is rejected

Here's the nasty one. When the backend rejected a refresh (expired/rotated/revoked token), it returned a 401 — but left the httpOnly refresh cookie in place. The browser, having no way to inspect that cookie, had no idea it was dead. So on the next page load it dutifully sent the same doomed cookie again... and again... forever. One dead session = an infinite private 401 stream.

The backend now clears the cookie on the rejected path, so the browser stops resending it:

import { clearRefreshTokenCookie } from '../../lib/refresh-cookie'

// inside the refresh handler, on the failure branch:
if (!result.ok) {
  clearRefreshTokenCookie(c)          // tell the browser to drop the dead cookie
  return c.json({ error: 'Unauthorized' }, 401)
}
Enter fullscreen mode Exit fullscreen mode

A Set-Cookie with an expired/Max-Age=0 value turns "replay forever" into "fail once, then go quiet."

Fix 3 — don't let anonymous visitors refresh at all

Because the refresh cookie is invisible to JavaScript, the client couldn't tell a logged-out visitor apart from a logged-in one at boot time — so it optimistically tried to refresh for everyone, including first-time anonymous visitors who never had a session. Each of those is a guaranteed 401.

The client can't read the cookie, but it can remember that a session once existed in this browser. A tiny localStorage marker does exactly that:

// The refresh cookie is httpOnly on a cross-origin API, so the client can't see
// it — this marker records "a session once existed in this browser" instead, so
// anonymous visitors never fire a doomed POST /v1/auth/refresh on page load.
const HAD_SESSION_MARKER_KEY = 'brewly-had-session'

function hadSessionInThisBrowser() {
  if (!import.meta.client) return false
  return localStorage.getItem(HAD_SESSION_MARKER_KEY) === '1'
}
Enter fullscreen mode Exit fullscreen mode

The marker is set when a session is stored and removed in clearSession() (right next to fix #2 on the client side). At boot, the store only attempts a refresh if hadSessionInThisBrowser() is true. Anonymous visitors now make zero refresh calls.

The rotation race (the suspect that wasn't the whole story)

The original hypothesis — two tabs sharing one refresh cookie, each rotating it and invalidating the other — was real, but it was a contributor, not the root cause. Once fixes 1–3 collapsed the volume of refresh calls, the race window shrank dramatically: fewer calls, fewer concurrent rotations. (We also added a short rotation grace so a just-rotated token isn't instantly rejected for an in-flight second request — but that's a smaller, separate story.)

Takeaways

  • A 401 storm is a "who's asking" problem as often as a "why rejected" problem. The rejection logic was correct the whole time.
  • httpOnly cross-origin cookies are invisible to the client — design for that. If the client can't see the cookie, give it a separate signal (a marker) so it can reason about session state without guessing.
  • Always clear a credential the server just rejected. Otherwise the client will replay it until the heat death of the browser tab.
  • Refresh on need, not on mount. Rotation schemes punish over-eager refreshing.

Three small, independent changes — no new backend architecture, no schema migration. Just deleting assumptions that had quietly stopped being true.


About the author

I'm Alex — a DevOps engineer building Brewly Store, a coffee e-commerce platform that runs entirely on Cloudflare's edge (Workers, D1, Pages). I write about edge architecture, auth, and the debugging stories that come with shipping to production.

Questions or war stories about edge auth? Drop them in the comments.

Top comments (0)