sellIntegration
lock_person

Authentication across front-end and back-end

From a no-code login wall to a hand-built session cookie — see exactly how auth flows across the stack.

2Auth approaches
50 usersFree Access seats
0 linesCode for Access
HttpOnlyCookie hardening
insights

What are we wiring up?

"Authentication" just means proving who a user is before letting them in. On Cloudflare you have two clean ways to do it, and this guide walks through both so you can pick the right one.

Approach A is Cloudflare Access (Zero Trust): identity sits in front of your whole app and you write zero code. Approach B is app-level sessions: your own Worker checks the password, stores a session in Workers KV, and hands the browser a cookie.

schemaThe two paths at a glance

Zero Trust

App login

Browser

Which auth?

Cloudflare Access

Your Worker

Protected app

Workers KV sessions

verified_user

A. Cloudflare Access

Identity check in front of the app. No code, managed in the Zero Trust dashboard. Great for internal tools.

cookie

B. Sessions in KV

Your Worker verifies the login, stores a session in KV, and sets a cookie. Full control, needs code. Great for public apps.

meeting_room

Think of it like…

Access is hiring a security guard for the whole building's front door. Building your own session is giving each visitor a numbered wristband at your own reception desk and checking that band at every room.

verified_user

Approach A — Cloudflare Access (no code)

Cloudflare Access puts a login wall in front of your app at the edge. The user never reaches your app until Access has confirmed their identity with a provider you trust (Google, GitHub, Microsoft Entra ID, or a one-time email PIN).

This is Single Sign-On (SSO): the user logs in once with their existing account and Access issues a signed token (a JWT) as a cookie. Your app can stay completely unaware of passwords.

schemaCloudflare Access SSO flow
"Your App""Identity Provider""Cloudflare Access""Browser""Your App""Identity Provider""Cloudflare Access""Browser"Open the app URLRedirect to loginSign in with GoogleIdentity confirmedCheck Access policySet signed JWT cookieRequest carries the JWTProtected page
code_off

Zero code

Add the app and a policy in the dashboard. Nothing to write, build, or deploy.

groups

Reuse company logins

Plug in Google Workspace, GitHub, or Entra ID so people use accounts they already have.

policy

Identity-based policies

Allow only @yourcompany.com emails, require MFA, or restrict by group.

history

Built-in audit log

Every login attempt is recorded — who, what, and when — with no extra work.

verified

Your app can still trust the token

Access forwards a signed Cf-Access-Jwt-Assertion header. If you want, your Worker can verify that JWT against Cloudflare's public keys to read the user's email — but you never have to handle the login itself.

cookie

Approach B — App-level sessions in KV

When you run your own sign-up and login (public app, custom UI, your own user table), you handle auth in code. The classic, robust pattern is the server-side session backed by a cookie.

The Worker checks the password, creates a random session id, stores the real session data in Workers KV with an expiry, and sends the browser a cookie holding only that id. On later requests the browser auto-sends the cookie and the Worker looks the session up in KV.

schemaApp-level login flow
"Workers KV""Worker""Browser""Workers KV""Worker""Browser"POST /login email and passwordVerify the password hashput session id and user jsonstored with a TTLSet-Cookie session idGET /me with the cookieget session iduser jsonProfile JSON
key

Opaque session id

The cookie holds a random id only. User data lives server-side in KV, never in the browser.

schedule

Auto-expiring

expirationTtl makes KV delete the session automatically — logout-by-timeout for free.

logout

Instant revoke

Delete the KV key and that session is dead immediately, even before it expires.

tune

Full control

Your routes, your cookie flags, your rules. You can add roles, refresh, anything.

lan

Why KV fits sessions

Sessions are read on almost every request and KV is built for read-heavy, globally-replicated lookups with a built-in TTL. That makes it a natural home for session data on Workers.

call_split

Which one should I use?

Pick by audience. If you are gating an internal tool or staff dashboard, Access wins — it's faster and safer than anything you'd hand-roll. If you are building a public product where strangers sign up, you need your own sessions.

schemaDecision: Access vs build-your-own

Yes

No

Yes

No

Yes

No

Need to protect something

Internal tool or staff app?

Use Cloudflare Access

Public app with user accounts?

Build sessions in KV

Just block bots on a form?

Add Turnstile

Login wall ready

badge

Choose Access when…

Internal apps, admin panels, staging sites, SSH/RDP, or anything for a known set of people.

public

Build your own when…

Public sign-up, custom login UI, social accounts, per-user data, or a mobile/SPA backend.

shield

Or just Turnstile…

If you only need to stop bots on a contact or signup form, add Turnstile and skip auth entirely.

merge

You can combine them

It's common to use Access for the admin area and your own KV sessions for end-user accounts in the same project. They don't conflict.

construction

Build it: a session Worker end to end

Here is approach B in full: create the KV namespaces, bind them, seed a demo user, then the Worker (login + middleware + logout) and a tiny front-end. Approach A needs no code — just the dashboard steps from the Access section.

  1. Create two KV namespaces

    One stores sessions, one stores the demo users. Wrangler prints an id for each.

    bash
    # Two namespaces: one for sessions, one for the demo user store
    npx wrangler kv namespace create SESSIONS
    npx wrangler kv namespace create USERS
  2. Bind them in wrangler.jsonc

    Paste the ids so the Worker can reach them as env.SESSIONS and env.USERS.

    json
    {
      "name": "auth-worker",
      "main": "src/index.js",
      "compatibility_date": "2025-01-01",
      "kv_namespaces": [
        { "binding": "SESSIONS", "id": "<paste-sessions-id>" },
        { "binding": "USERS", "id": "<paste-users-id>" }
      ]
    }
  3. Seed a demo user

    Store one user whose passwordHash is the SHA-256 of the password "hunter2".

    bash
    # Seed one demo user. passwordHash below is SHA-256 of "hunter2".
    npx wrangler kv key put --binding USERS \
      "user:alice@example.com" \
      '{"email":"alice@example.com","passwordHash":"f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"}' \
      --remote

The Worker: login, middleware, logout

Note the password check uses SHA-256 only to keep the demo short. In production, hash passwords with a real KDF (key derivation function) such as Argon2id, scrypt, or bcrypt, or PBKDF2 via Web Crypto with a high iteration count and a per-user salt.

jssrc/index.js
// src/index.js -- app-level sessions stored in Workers KV
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Public: log in and receive a session cookie
    if (url.pathname === "/login" && request.method === "POST") {
      return handleLogin(request, env);
    }
    // Public: drop the session
    if (url.pathname === "/logout" && request.method === "POST") {
      return handleLogout(request, env);
    }
    // Protected: only reachable with a valid session cookie
    if (url.pathname === "/me") {
      return requireSession(request, env, (session) =>
        Response.json({ email: session.email })
      );
    }
    return new Response("Not found", { status: 404 });
  },
};

// --- Login: verify password, create a session in KV, set a cookie ---
async function handleLogin(request, env) {
  const { email, password } = await request.json();

  const record = await env.USERS.get(`user:${email}`);
  if (!record) return new Response("Invalid login", { status: 401 });
  const user = JSON.parse(record);

  // DEMO ONLY: comparing SHA-256 hashes.
  // In production hash passwords with a real KDF (Argon2id / scrypt / bcrypt,
  // or PBKDF2 via Web Crypto with many iterations and a per-user salt).
  if ((await sha256(password)) !== user.passwordHash) {
    return new Response("Invalid login", { status: 401 });
  }

  // Opaque random id -- the cookie holds only this id, never user data.
  const sessionId = crypto.randomUUID();
  const session = { email: user.email, createdAt: Date.now() };
  const ttl = 60 * 60 * 24 * 7; // 7 days, in seconds

  await env.SESSIONS.put(`session:${sessionId}`, JSON.stringify(session), {
    expirationTtl: ttl,
  });

  return new Response(JSON.stringify({ ok: true }), {
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": cookie("session", sessionId, ttl),
    },
  });
}

// --- Middleware: read the cookie, look the session up in KV ---
async function requireSession(request, env, handler) {
  const id = getCookie(request, "session");
  if (!id) return new Response("Unauthorized", { status: 401 });

  const record = await env.SESSIONS.get(`session:${id}`);
  if (!record) return new Response("Unauthorized", { status: 401 });

  return handler(JSON.parse(record));
}

// --- Logout: delete the session and clear the cookie ---
async function handleLogout(request, env) {
  const id = getCookie(request, "session");
  if (id) await env.SESSIONS.delete(`session:${id}`);
  return new Response(JSON.stringify({ ok: true }), {
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": cookie("session", "", 0),
    },
  });
}

// --- Helpers ---
function cookie(name, value, maxAge) {
  return [
    `${name}=${value}`,
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
    "Path=/",
    `Max-Age=${maxAge}`,
  ].join("; ");
}

function getCookie(request, name) {
  const header = request.headers.get("Cookie") || "";
  for (const part of header.split(";")) {
    const [key, ...rest] = part.trim().split("=");
    if (key === name) return rest.join("=");
  }
  return null;
}

async function sha256(text) {
  const data = new TextEncoder().encode(text);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

The front-end: a login form

credentials: "include" is the key detail — it tells the browser to store the Set-Cookie response and resend the cookie on later requests, so /me just works.

htmllogin.html
<!-- A plain login form posting JSON to the Worker -->
<form id="login">
  <input name="email" type="email" placeholder="Email" required />
  <input name="password" type="password" placeholder="Password" required />
  <button>Log in</button>
</form>

<script>
  const form = document.getElementById("login");
  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch("/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include", // let the browser store & resend the cookie
      body: JSON.stringify({
        email: form.email.value,
        password: form.password.value,
      }),
    });
    if (res.ok) {
      // The session cookie is set; protected fetches now work automatically.
      const me = await fetch("/me", { credentials: "include" }).then((r) => r.json());
      alert(`Welcome, ${me.email}`);
    } else {
      alert("Login failed");
    }
  });
</script>
gpp_maybe

Before going live

Always serve over HTTPS (the Secure flag needs it), keep cookies HttpOnly so JavaScript can't read them, use a real password KDF, and add rate limiting plus Turnstile on the login route to slow down credential-stuffing.

school

Key terms, in plain words

verified_user

Zero Trust

Never trust by default; verify identity on every request, inside or outside the network. Access is built on this idea.

badge

Session

Server-side proof that a user already logged in. Stored in KV here, identified by a random id.

cookie

Cookie

A small value the browser stores and auto-resends to your domain. We put only the session id in it.

token

JWT

A signed, self-contained token that carries claims (like the user's email). Access issues one after SSO.

shield_lock

HttpOnly / Secure

Cookie flags: HttpOnly hides it from JavaScript (anti-XSS); Secure sends it only over HTTPS.

swap_horiz

SameSite

Controls whether the cookie rides along on cross-site requests. Lax is a sensible default against CSRF.

password

KDF / password hash

A slow, salted one-way function (Argon2/bcrypt/scrypt) for storing passwords safely. Not plain SHA-256.

login

SSO

Log in once with one identity provider and gain access to multiple apps. That's the Access experience.

tips_and_updates

Pitfalls & billing

report

Never store passwords or user data in the cookie

The cookie should carry only an opaque session id. Anything you put in a plain cookie is visible and tamperable by the user; keep the real data in KV on the server.

  • KV is eventually consistent: a brand-new session is readable almost immediately, but allow a moment for global propagation in edge cases.
  • Set expirationTtl on every session so stale sessions clean themselves up; mirror it in the cookie Max-Age.
  • Cloudflare Zero Trust (Access) is free for up to 50 users — perfect for internal tools.
  • KV pricing is per read/write/delete with a generous free tier; one session lookup per request is cheap.
  • Add Turnstile to the login form to blunt brute-force and credential-stuffing attacks.
  • On logout, delete the KV key AND send a Max-Age=0 cookie so both sides forget the session.
auto_awesome

Rule of thumb

Internal? Reach for Access first. Public sign-up? Own your sessions in KV. Either way, let Cloudflare's edge do the heavy lifting.