Skip to content
Back to Blog
Software Development

Cookie-based auth using Cloudflare Workers and the BFF Pattern

Tokens in the browser are always a compromise. Here's how to use Cloudflare Workers as a BFF, keep them server-side, and trade client-side auth headaches for a simple session cookie.

Davor Pihac
Davor Pihac Software Engineer
Cookie-based auth using Cloudflare Workers and the BFF Pattern
Table of Contents

Auth in SPAs is one of those things that sounds simple and then isn’t.

You get an access token from your identity provider. Now what? Store it in localStorage? Any script on the page can read it. Keep it in memory? Gone on refresh. Set a cookie from the client? You lose control over the flags. Every option has a hole somewhere.

The BFF (Backend for Frontend) pattern avoids the problem entirely. Instead of the client dealing with tokens, a server-side layer handles the exchange and keeps the token to itself. The client gets an opaque session cookie. It doesn’t know what the token looks like, and it doesn’t need to.

I use a Cloudflare Worker for this. It sits between the SPA and the identity provider, does the OAuth dance, and stores the access token in KV or a Durable Object.

The browser gets an HttpOnly cookie. It never touches the real token.

Why this approach?

Most SPAs store tokens in localStorage because it’s the simplest option that survives page reloads. The problem: any JavaScript running on your page can read it. XSS vulnerabilities are still common enough that you can’t just dismiss the risk.

The BFF takes a different approach. The token stays server-side. The client gets back a session cookie — opaque, HttpOnly, unreadable by scripts. The SPA doesn’t know the token exists and doesn’t need to.

How it Works

  1. The user logs in through your identity provider (e.g. Auth0, Cognito).
  2. The identity provider redirects back to the Cloudflare Worker with an authorization code.
  3. The Worker exchanges the code for tokens, stores the access token (e.g. in KV or a Durable Object), and sets a signed HttpOnly session cookie.
  4. Subsequent API requests from the SPA carry the cookie automatically.
  5. The Worker validates the session, fetches the access token from KV or DO, and forwards it to upstream services.
// Cloudflare Worker — simplified callback handler
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/auth/callback") {
      const code = url.searchParams.get("code");

      const tokenResponse = await fetch("https://your-idp.example.com/oauth/token", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code: code!,
          redirect_uri: env.REDIRECT_URI,
          client_id: env.CLIENT_ID,
          client_secret: env.CLIENT_SECRET,
        }),
      });

      const { access_token } = await tokenResponse.json<{ access_token: string }>();

      const sessionId = crypto.randomUUID();
      await env.SESSIONS.put(sessionId, access_token, { expirationTtl: 3600 });

      return new Response(null, {
        status: 302,
        headers: {
          Location: "/",
          "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/`,
        },
      });
    }

    return new Response("Not found", { status: 404 });
  },
};

Validating the Session on Each Request

On protected routes, the Worker reads the cookie, looks up the session in KV, and either forwards the request with an Authorization header or returns a 401.

const cookie = request.headers.get("Cookie") ?? "";
const sessionId = parseCookie(cookie)["session"];

if (!sessionId) {
  return new Response("Unauthorized", { status: 401 });
}

const accessToken = await env.SESSIONS.get(sessionId);
if (!accessToken) {
  return new Response("Unauthorized", { status: 401 });
}

// Forward to upstream API
return fetch("https://api.example.com/data", {
  headers: { Authorization: `Bearer ${accessToken}` },
});

Key Security Considerations

  • HttpOnly cookies cannot be read by JavaScript, eliminating XSS token theft.
  • Secure ensures the cookie is only sent over HTTPS.
  • SameSite=Lax provides CSRF protection for most use cases; use Strict if cross-site navigation is not required.
  • Rotate session IDs on login and invalidate them on logout by deleting the KV entry.

More than just security

Security is the obvious reason to go with this approach, but there are a few other advantages worth mentioning.

Per-device session management

Every login creates a unique session ID, so you can track and manage each session independently. List active sessions, let users revoke a specific one, or wipe everything on a password reset. With a JWT in localStorage, that’s not really an option — you can’t un-issue a token that’s already on the client.

No client-side refresh logic

Instead of sending a refresh token to the browser, the BFF stores it in a Durable Object alongside the access token. When the access token is close to expiring, the DO refreshes it in the background. The user never hits a token-expired error and the frontend doesn’t need any refresh logic at all.

No mandatory auth provider

You don’t actually need Auth0, Cognito, or anything similar to make this work. They’re worth it if you need social login or MFA, but for plain username and password you don’t need to pay for any of that. Hash the password server-side, create a session entry, set the cookie — done. Bring in an external provider when you have a real reason to, not because tokens needed somewhere safe to live.

Passkeys fit in nicely

Since the BFF already owns session creation, adding passkey (WebAuthn) support is a natural next step. The flow is similar to the OAuth callback — just replace the identity provider with the WebAuthn ceremony.

During registration, the Worker generates a challenge via the WebAuthn API. The browser creates a credential with the user’s device biometrics or security key, and the Worker stores the public key in KV or D1.

During login, the Worker sends a challenge, the browser signs it with the private key that never leaves the device, and the Worker verifies the signature. If it checks out — create a session, set the cookie, same as before.

// After successful passkey verification
const sessionId = crypto.randomUUID();
await env.SESSIONS.put(sessionId, JSON.stringify({
  userId: user.id,
  credentialId: credential.id,
  authenticatedAt: Date.now(),
}), { expirationTtl: 3600 });

return new Response(null, {
  status: 302,
  headers: {
    Location: "/",
    "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/`,
  },
});

No passwords to hash, no tokens from a third-party provider, no phishing risk. The private key lives on the user’s device and the session cookie is the only thing the browser handles.

You get passwordless auth without adding any external dependency.

Instant revocation

Deleting a KV entry is instant. No TTL to wait out, no blocklist to manage. Log out, suspicious activity, admin action — session gone the moment you delete the record.

Request enrichment at the edge

The Worker can inject user metadata into upstream requests before forwarding them. X-User-Id, X-Roles, whatever your services need. They don’t have to parse and verify a JWT, they just read a header.

What about mobile apps and API consumers?

Cookies are a browser concept. A React Native app, a CLI tool, or a server-to-server integration won’t get an HttpOnly cookie the same way.

The good news: the same pattern works. The difference is just the delivery mechanism. Instead of Set-Cookie, the Worker returns the opaque session ID in the response body. The mobile app stores it in the platform keychain (Keychain on iOS, EncryptedSharedPreferences or Keystore on Android) and sends it as a header on every request.

Browser:   Set-Cookie: session=uuid  →  cookie sent automatically
Mobile:    { "session_token": "uuid" }  →  stored in keychain, sent as header
Worker:    same session lookup either way

The Worker doesn’t care how the session ID arrives. Cookie or header, it looks it up in KV, finds the real access token, and forwards it upstream. All the same advantages apply — per-device revocation, no refresh logic on the client, tokens never leave the server.

You can have the same Worker serve both: cookie-based sessions for web clients, header-based sessions for mobile. A few lines of conditional logic in the session middleware.

For true server-to-server integrations (cron jobs, internal services), the BFF isn’t the right fit. Those clients should use standard OAuth with client credentials and bearer tokens directly.

The BFF is for end-user clients that shouldn’t hold secrets.

Trade-offs

There are a few things you should keep in mind.

Added latency

Every request now hits KV or a Durable Object before reaching the upstream API. On Workers that’s typically under 10ms, but it’s not zero. For most apps this is fine. If you’re building something latency-critical at high throughput, measure it.

Eventual consistency

KV is eventually consistent. If you revoke a session, there can be a brief window (usually seconds) where a read from a different region still finds the old entry. Durable Objects are strongly consistent but cost more.

Pick based on what you actually need — for most apps, KV’s consistency model is good enough.

Single point of failure

The Worker becomes a gateway. All authenticated traffic flows through it. If the session logic has a bug, everything behind it breaks. This isn’t unique to the BFF pattern — any reverse proxy or API gateway has the same property. But it means your auth layer needs solid tests and monitoring.

Session state management

You also have state to manage now. Sessions expire, need cleanup, and take up storage. KV’s TTL handles most of this automatically, but it’s still more moving parts than a stateless JWT.

Wrapping Up

Cloudflare Workers fit this pattern well. Globally distributed, no cold starts, and KV with Durable Objects covers session storage without much overhead. The pattern works for both web and mobile, and the trade-offs are manageable for most applications.

If you’re already running Workers for something, adding a BFF layer is not a big lift.

Related Posts

Why I Chose Hono over ASP.NET Core
Software Development

Why I Chose Hono over ASP.NET Core

C# was my first professional love. ASP.NET Core is a fantastic framework, but for APIs, Hono is my new go-to. Here's why.