Authentication
We’ve baked authentication right into the standard starter, giving you everything you need to handle users, sessions, and logins out of the box. The standard starter uses passkeys (WebAuthn) for passwordless authentication (keys can be shared on multiple devices), session persistence via Cloudflare Durable Objects, and bot protection with Cloudflare Turnstile. The database layer is powered by Cloudflare D1 and Prisma.
Setup
Below covers the steps needed to getting authentication working in your deployments.
Wrangler Setup
Within your project’s wrangler.jsonc
:
If you haven’t already, replace the __change_me__
placeholders with a name for your application.
Then, create a new D1 database:
npx wrangler d1 create my-project-db
Copy the database ID provided and paste it into your project’s wrangler.jsonc
file:
{ "d1_databases": [ { "binding": "DB", "database_name": "my-project-db", "database_id": "your-database-id" } ]}
Environment Variables
The following environment variables are used for authentication:
WEBAUTHN_APP_NAME
: The application name shown in WebAuthn prompts (defaults to your application name)WEBAUTHN_RP_ID
: The relying party ID for WebAuthn (defaults to the request hostname)AUTH_SECRET_KEY
: Secret key for signing session tokens (defaults to a development key in development)
For production deployments, these values are automatically configured by the deployment script. You can override them if needed:
Customizing WebAuthn Relying Party ID
By default, the RP_ID is set to the hostname of each request. You can override this by setting the WEBAUTHN_RP_ID
environment variable:
npx wrangler secret put WEBAUTHN_RP_ID
When prompted, enter your production domain (e.g., my-app.example.com
).
Note: The WEBAUTHN_RP_ID must be a valid domain that matches your application’s origin. For security reasons, WebAuthn will not work if these don’t match.
Setting up Session Secret Key
For production, generate a strong AUTH_SECRET_KEY
for signing session IDs:
# Generate a 32-byte random key and encode it as base64openssl rand -base64 32
Then set this key as a Cloudflare secret:
npx wrangler secret put AUTH_SECRET_KEY
Never use the same secret key for development and production environments, and avoid committing your secret keys to version control.
Optional: Bot Protection with Turnstile
You can optionally enable bot protection for user registration using Cloudflare Turnstile. To enable this:
-
Create a new Turnstile widget:
- Set Widget Mode to
invisible
- Add your application’s hostname to Allowed hostnames, e.g.,
my-app.example.com
.
- Set Widget Mode to
-
Copy your Site Key into your application’s
LoginPage.tsx
:
const TURNSTILE_SITE_KEY = "<YOUR_SITE_KEY>";
- Set your Turnstile Secret Key via Cloudflare secrets for production:
npx wrangler secret put TURNSTILE_SECRET_KEY
- Update your registration function to include Turnstile verification:
import { verifyTurnstileToken } from "rwsdk/auth";
// In your registration handler:const turnstileResponse = await verifyTurnstileToken(token, env);if (!turnstileResponse.success) { throw new Error("Bot protection verification failed");}
Security Considerations
Username vs Email
The authentication system intentionally uses usernames instead of emails. This decision prevents enumeration attacks and avoids requiring valid email addresses for registration.
Authentication Flow
Authentication uses credential IDs from the authenticator instead of usernames or emails, significantly mitigating enumeration risks.
Bot Protection
When enabled, registration is protected using Cloudflare Turnstile to prevent automated bot registrations. While Cloudflare’s built-in bot detection will identify and block malicious patterns over time, Turnstile provides immediate verification before registration to prevent bot registrations from the start.
How it all works
Retrieving a Session
Sessions are handled using cookies (for session IDs) and Durable Objects (for storing session data).
When a request comes in, we:
- Check for a
session_id
cookie. - Verify that the session ID is valid by checking its signature - this lets us be sure it was us that issued it
- If it’s valid, load the session data from the Durable Object.
- If there’s an active session, we pull the user ID from it and load the user from the database (D1 + Prisma).
import { defineApp, ErrorResponse } from "rwsdk/worker";16 collapsed lines
import { route, render, prefix } from "rwsdk/router";import { Document } from "@/app/Document";import { Home } from "@/app/pages/Home";import { setCommonHeaders } from "@/app/headers";import { userRoutes } from "@/app/pages/user/routes";import { sessions, setupSessionStore } from "./session/store";import { Session } from "./session/durableObject";import { type User, db, setupDb } from "@/db";import { env } from "cloudflare:workers";export { SessionDurableObject } from "./session/durableObject";
export type AppContext = { session: Session | null; user: User | null;};
export default defineApp([1 collapsed line
setCommonHeaders(), async ({ ctx, request, headers }) => {2 collapsed lines
await setupDb(env); setupSessionStore(env);
try { ctx.session = await sessions.load(request); } catch (error) { if (error instanceof ErrorResponse && error.code === 401) { await sessions.remove(request, headers); headers.set("Location", "/user/login");
1 collapsed line
return new Response(null, { status: 302,15 collapsed lines
headers, }); }
throw error; }
if (ctx.session?.userId) { ctx.user = await db.user.findUnique({ where: { id: ctx.session.userId, }, }); } }, render(Document, [ route("/", () => new Response("Hello, World!")), route("/protected", [ ({ ctx }) => { if (!ctx.user) { return new Response(null, { status: 302, headers: { Location: "/user/login" }, }); } }, Home, ]), prefix("/user", userRoutes), ]),]);
Why Durable Objects?
Instead of keeping session data in multiple places where we’d have to worry about syncing and stale data, each session is stored in a single Durable Object instance. This means we don’t have to deal with session data being out of date or lingering after logout—it’s all in one place, making revocation straightforward. On top of this, when a session is active, it stays in memory, so lookups are faster without extra database queries.
For more on Durable Objects, see Cloudflare’s documentation.
Logging In
We use passkeys (WebAuthn) for authentication. This allows users to log in without passwords, using their authenticator, browser or device to handle authentication securely.
For more on passkeys, see passkeys.dev.
Login Flow
- The user clicks Login.
- The frontend calls a server action to get a WebAuthn challenge.
- The challenge is stored in the session store using
sessions.save()
. - The user’s authenticator signs the challenge.
- The signed challenge is sent back to the server and verified.
- If successful, the session is updated with the user ID.
18 collapsed lines
"use client";
import { useState, useTransition } from "react";import { startAuthentication, startRegistration,} from "@simplewebauthn/browser";import { finishPasskeyLogin, finishPasskeyRegistration, startPasskeyLogin, startPasskeyRegistration,} from "./functions";
export function Login() { const [username, setUsername] = useState(""); const [result, setResult] = useState(""); const [isPending, startTransition] = useTransition();
5 collapsed lines
const passkeyLogin = async () => { // 1. Get a challenge from the worker const options = await startPasskeyLogin();
// 2. Ask the browser to sign the challenge const login = await startAuthentication({ optionsJSON: options });
// 3. Give the signed challenge to the worker to finish the login process const success = await finishPasskeyLogin(login);
if (!success) { setResult("Login failed"); } else { setResult("Login successful!"); } };
const passkeyRegister = async () => { // 1. Get a challenge from the worker const options = await startPasskeyRegistration(username);
39 collapsed lines
// 2. Ask the browser to sign the challenge const registration = await startRegistration({ optionsJSON: options });
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration(username, registration);
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> <button onClick={handlePerformPasskeyLogin} disabled={isPending}> {isPending ? <>...</> : "Login with passkey"} </button> <button onClick={handlePerformPasskeyRegister} disabled={isPending}> {isPending ? <>...</> : "Register with passkey"} </button> {result && <div>{result}</div>} </> );}
109 collapsed lines
"use server";import { generateRegistrationOptions, generateAuthenticationOptions, verifyRegistrationResponse, verifyAuthenticationResponse, RegistrationResponseJSON, AuthenticationResponseJSON,} from "@simplewebauthn/server";
import { sessions } from "@/session/store";import { requestInfo } from "rwsdk/worker";import { db } from "@/db";import { env } from "cloudflare:workers";
const IS_DEV = process.env.NODE_ENV === "development";
function getWebAuthnConfig(request: Request) { const rpID = env.WEBAUTHN_RP_ID ?? new URL(request.url).hostname; const rpName = IS_DEV ? "Development App" : env.WEBAUTHN_APP_NAME; return { rpName, rpID, };}
export async function startPasskeyRegistration(username: string) { const { rpName, rpID } = getWebAuthnConfig(requestInfo.request); const { headers } = requestInfo;
const options = await generateRegistrationOptions({ rpName, rpID, userName: username, authenticatorSelection: { // Require the authenticator to store the credential, enabling a username-less login experience residentKey: "required", // Prefer user verification (biometric, PIN, etc.), but allow authentication even if it's not available userVerification: "preferred", }, });
await sessions.save(headers, { challenge: options.challenge });
return options;}
export async function startPasskeyLogin() { const { rpID } = getWebAuthnConfig(requestInfo.request); const { headers } = requestInfo;
const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", allowCredentials: [], });
await sessions.save(headers, { challenge: options.challenge });
return options;}
export async function finishPasskeyRegistration( username: string, registration: RegistrationResponseJSON,) { const { request, headers } = requestInfo; const { origin } = new URL(request.url);
const session = await sessions.load(request); const challenge = session?.challenge;
if (!challenge) { return false; }
const verification = await verifyRegistrationResponse({ response: registration, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: env.WEBAUTHN_RP_ID || new URL(request.url).hostname, });
if (!verification.verified || !verification.registrationInfo) { return false; }
await sessions.save(headers, { challenge: null });
const user = await db.user.create({ data: { username, }, });
await db.credential.create({ data: { userId: user.id, credentialId: verification.registrationInfo.credential.id, publicKey: verification.registrationInfo.credential.publicKey, counter: verification.registrationInfo.credential.counter, }, });
return true;}
export async function finishPasskeyLogin(login: AuthenticationResponseJSON) { const { request, headers } = requestInfo; const { origin } = new URL(request.url);2 collapsed lines
const session = await sessions.load(request); const challenge = session?.challenge;3 collapsed lines
if (!challenge) { return false; }
51 collapsed lines
const credential = await db.credential.findUnique({ where: { credentialId: login.id, }, });
if (!credential) { return false; }
const verification = await verifyAuthenticationResponse({ response: login, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: env.WEBAUTHN_RP_ID || new URL(request.url).hostname, requireUserVerification: false, credential: { id: credential.credentialId, publicKey: credential.publicKey, counter: credential.counter, }, });
if (!verification.verified) { return false; }
await db.credential.update({ where: { credentialId: login.id, }, data: { counter: verification.authenticationInfo.newCounter, }, });
const user = await db.user.findUnique({ where: { id: credential.userId, }, });
if (!user) { return false; }
await sessions.save(headers, { userId: user.id, challenge: null, });
return true;}
Registering Users
Registration follows a very similar 3-step WebAuthn process as login:
18 collapsed lines
"use client";
import { useState, useTransition } from "react";import { startAuthentication, startRegistration,} from "@simplewebauthn/browser";import { finishPasskeyLogin, finishPasskeyRegistration, startPasskeyLogin, startPasskeyRegistration,} from "./functions";
export function Login() { const [username, setUsername] = useState(""); const [result, setResult] = useState(""); const [isPending, startTransition] = useTransition();
22 collapsed lines
const passkeyLogin = async () => { // 1. Get a challenge from the worker const options = await startPasskeyLogin();
// 2. Ask the browser to sign the challenge const login = await startAuthentication({ optionsJSON: options });
// 3. Give the signed challenge to the worker to finish the login process const success = await finishPasskeyLogin(login);
if (!success) { setResult("Login failed"); } else { setResult("Login successful!"); } };
const passkeyRegister = async () => { // 1. Get a challenge from the worker const options = await startPasskeyRegistration(username);
// 2. Ask the browser to sign the challenge const registration = await startRegistration({ optionsJSON: options });
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration(username, registration);
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <>16 collapsed lines
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> <button onClick={handlePerformPasskeyLogin} disabled={isPending}> {isPending ? <>...</> : "Login with passkey"} </button> <button onClick={handlePerformPasskeyRegister} disabled={isPending}> {isPending ? <>...</> : "Register with passkey"} </button> {result && <div>{result}</div>} </> );}
One difference: we protect registrations with an extra layer of bot protection using Cloudflare Turnstile.
Why Turnstile?
Cloudflare has built-in bot protection, but it works by detecting and blocking malicious patterns over time. That means automated registrations can still get through until Cloudflare picks up on them. Turnstile prevents this from becoming an issue in the first place by requiring a lightweight challenge before registration happens, stopping bots at the point of entry.
Turnstile in the Registration Flow
- The Turnstile client script is loaded on the page.
- The frontend calls
useTurnstile()
to generate a challenge token. - The token is sent to the backend along with the WebAuthn data.
- The backend verifies the token using
verifyTurnstileToken()
before completing registration.
15 collapsed lines
"use client";
import { useState, useTransition } from "react";import { startAuthentication, startRegistration,} from "@simplewebauthn/browser";import { finishPasskeyLogin, finishPasskeyRegistration, startPasskeyLogin, startPasskeyRegistration,} from "./functions";
export function Login() { const [username, setUsername] = useState(""); const [result, setResult] = useState("");1 collapsed line
const [isPending, startTransition] = useTransition();
3 collapsed lines
const passkeyLogin = async () => { // 1. Get a challenge from the worker const options = await startPasskeyLogin();
18 collapsed lines
// 2. Ask the browser to sign the challenge const login = await startAuthentication({ optionsJSON: options });
// 3. Give the signed challenge to the worker to finish the login process const success = await finishPasskeyLogin(login);
if (!success) { setResult("Login failed"); } else { setResult("Login successful!"); } };
const passkeyRegister = async () => { // 1. Get a challenge from the worker const options = await startPasskeyRegistration(username);
// 2. Ask the browser to sign the challenge const registration = await startRegistration({ optionsJSON: options });6 collapsed lines
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration(username, registration);
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };6 collapsed lines
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <>9 collapsed lines
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> <button onClick={handlePerformPasskeyLogin} disabled={isPending}> {isPending ? <>...</> : "Login with passkey"} </button> <button onClick={handlePerformPasskeyRegister} disabled={isPending}> {isPending ? <>...</> : "Register with passkey"} </button>4 collapsed lines
{result && <div>{result}</div>} </> );}
38 collapsed lines
"use server";import { generateRegistrationOptions, generateAuthenticationOptions, verifyRegistrationResponse, verifyAuthenticationResponse, RegistrationResponseJSON, AuthenticationResponseJSON,} from "@simplewebauthn/server";
import { sessions } from "@/session/store";import { requestInfo } from "rwsdk/worker";import { db } from "@/db";import { env } from "cloudflare:workers";
const IS_DEV = process.env.NODE_ENV === "development";
function getWebAuthnConfig(request: Request) { const rpID = env.WEBAUTHN_RP_ID ?? new URL(request.url).hostname; const rpName = IS_DEV ? "Development App" : env.WEBAUTHN_APP_NAME; return { rpName, rpID, };}
export async function startPasskeyRegistration(username: string) { const { rpName, rpID } = getWebAuthnConfig(requestInfo.request); const { headers } = requestInfo;
const options = await generateRegistrationOptions({ rpName, rpID, userName: username, authenticatorSelection: { // Require the authenticator to store the credential, enabling a username-less login experience residentKey: "required", // Prefer user verification (biometric, PIN, etc.), but allow authentication even if it's not available userVerification: "preferred",4 collapsed lines
}, });
await sessions.save(headers, { challenge: options.challenge });
2 collapsed lines
return options;}
export async function startPasskeyLogin() { const { rpID } = getWebAuthnConfig(requestInfo.request); const { headers } = requestInfo;
const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred",39 collapsed lines
allowCredentials: [], });
await sessions.save(headers, { challenge: options.challenge });
return options;}
export async function finishPasskeyRegistration( username: string, registration: RegistrationResponseJSON,) { const { request, headers } = requestInfo; const { origin } = new URL(request.url);
const session = await sessions.load(request); const challenge = session?.challenge;
if (!challenge) { return false; }
const verification = await verifyRegistrationResponse({ response: registration, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: env.WEBAUTHN_RP_ID || new URL(request.url).hostname, });
if (!verification.verified || !verification.registrationInfo) { return false; }
await sessions.save(headers, { challenge: null });
const user = await db.user.create({ data: { username, }, });77 collapsed lines
await db.credential.create({ data: { userId: user.id, credentialId: verification.registrationInfo.credential.id, publicKey: verification.registrationInfo.credential.publicKey, counter: verification.registrationInfo.credential.counter, }, });
return true;}
export async function finishPasskeyLogin(login: AuthenticationResponseJSON) { const { request, headers } = requestInfo; const { origin } = new URL(request.url);
const session = await sessions.load(request); const challenge = session?.challenge;
if (!challenge) { return false; }
const credential = await db.credential.findUnique({ where: { credentialId: login.id, }, });
if (!credential) { return false; }
const verification = await verifyAuthenticationResponse({ response: login, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: env.WEBAUTHN_RP_ID || new URL(request.url).hostname, requireUserVerification: false, credential: { id: credential.credentialId, publicKey: credential.publicKey, counter: credential.counter, }, });
if (!verification.verified) { return false; }
await db.credential.update({ where: { credentialId: login.id, }, data: { counter: verification.authenticationInfo.newCounter, }, });
const user = await db.user.findUnique({ where: { id: credential.userId, }, });
if (!user) { return false; }
await sessions.save(headers, { userId: user.id, challenge: null, });
return true;}