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.
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.
A. Cloudflare Access
Identity check in front of the app. No code, managed in the Zero Trust dashboard. Great for internal tools.
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.
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.
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.
Zero code
Add the app and a policy in the dashboard. Nothing to write, build, or deploy.
Reuse company logins
Plug in Google Workspace, GitHub, or Entra ID so people use accounts they already have.
Identity-based policies
Allow only @yourcompany.com emails, require MFA, or restrict by group.
Built-in audit log
Every login attempt is recorded — who, what, and when — with no extra work.
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.
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.
Opaque session id
The cookie holds a random id only. User data lives server-side in KV, never in the browser.
Auto-expiring
expirationTtl makes KV delete the session automatically — logout-by-timeout for free.
Instant revoke
Delete the KV key and that session is dead immediately, even before it expires.
Full control
Your routes, your cookie flags, your rules. You can add roles, refresh, anything.
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.
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.
Choose Access when…
Internal apps, admin panels, staging sites, SSH/RDP, or anything for a known set of people.
Build your own when…
Public sign-up, custom login UI, social accounts, per-user data, or a mobile/SPA backend.
Or just Turnstile…
If you only need to stop bots on a contact or signup form, add Turnstile and skip auth entirely.
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.
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.
Create two KV namespaces
One stores sessions, one stores the demo users. Wrangler prints an id for each.
# Two namespaces: one for sessions, one for the demo user store npx wrangler kv namespace create SESSIONS npx wrangler kv namespace create USERSBind them in wrangler.jsonc
Paste the ids so the Worker can reach them as env.SESSIONS and env.USERS.
{ "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>" } ] }Seed a demo user
Store one user whose passwordHash is the SHA-256 of the password "hunter2".
# 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.
// 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.
<!-- 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>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.
Key terms, in plain words
Zero Trust
Never trust by default; verify identity on every request, inside or outside the network. Access is built on this idea.
Session
Server-side proof that a user already logged in. Stored in KV here, identified by a random id.
Cookie
A small value the browser stores and auto-resends to your domain. We put only the session id in it.
JWT
A signed, self-contained token that carries claims (like the user's email). Access issues one after SSO.
HttpOnly / Secure
Cookie flags: HttpOnly hides it from JavaScript (anti-XSS); Secure sends it only over HTTPS.
SameSite
Controls whether the cookie rides along on cross-site requests. Lax is a sensible default against CSRF.
KDF / password hash
A slow, salted one-way function (Argon2/bcrypt/scrypt) for storing passwords safely. Not plain SHA-256.
SSO
Log in once with one identity provider and gain access to multiple apps. That's the Access experience.
Pitfalls & billing
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.
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.
Related products
menu_bookOfficial docsopen_in_new