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, session persistence via Cloudflare Durable Objects, and bot protection with Cloudflare Turnstile. The database layer is powered by Cloudflare D1 and Prisma.
Setup
The Quick Start Guide should get you set up and ready for using authentication for development locally.
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", }, ],}
Setting up WebAuthn Relying Party ID (RP_ID
)
For production, set your domain as the RP_ID
via Cloudflare secrets:
wrangler secret put RP_ID
When prompted, enter your production domain (e.g., my-app.example.com
).
Note: The 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 SECRET_KEY
for signing session IDs. You can generate a secure random key using OpenSSL:
# Generate a 32-byte random key and encode it as base64openssl rand -base64 32
Then set this key as a Cloudflare secret:
wrangler secret put SECRET_KEY
Never use the same secret key for development and production environments, and avoid committing your secret keys to version control.
Setting up Cloudflare Turnstile (Bot Protection)
-
Create a new Turnstile widget:
- Set Widget Mode to
invisible
- Add your application’s hostname to Allowed hostnames, e.g.,
my-project-name.example.com
.
- Set Widget Mode to
-
Copy your Site Key into your application’s
Login.tsx
:
const TURNSTILE_SITE_KEY = "<YOUR_SITE_KEY>";
- Set your Turnstile Secret Key via Cloudflare secrets for production:
wrangler secret put TURNSTILE_SECRET_KEY
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 "@redwoodjs/sdk/worker";16 collapsed lines
import { route, render, prefix } from "@redwoodjs/sdk/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 { db, setupDb } from "./db";import type { User } from "@prisma/client";export { SessionDurableObject } from "./session/durableObject";
export type AppContext = { session: Session | null; user: User | null;};
export default defineApp<AppContext>([1 collapsed line
setCommonHeaders(), async ({ env, appContext, request, headers }) => {2 collapsed lines
await setupDb(env); setupSessionStore(env);
try { appContext.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 (appContext.session?.userId) { appContext.user = await db.user.findUnique({ where: { id: appContext.session.userId, }, }); } }, render(Document, [ route("/", () => new Response("Hello, World!")), route("/protected", [ ({ appContext }) => { if (!appContext.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";import { useTurnstile } from "@redwoodjs/sdk/turnstile";
// >>> Replace this with your own Cloudflare Turnstile site keyconst TURNSTILE_SITE_KEY = "1x00000000000000000000AA";
export function Login() {5 collapsed lines
const [username, setUsername] = useState(""); const [result, setResult] = useState(""); const [isPending, startTransition] = useTransition(); const turnstile = useTurnstile(TURNSTILE_SITE_KEY);
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!"); } };50 collapsed lines
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 });
const turnstileToken = await turnstile.challenge();
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration( username, registration, turnstileToken, );
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <> <div ref={turnstile.ref} /> <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 { HandlerOptions } from "@redwoodjs/sdk/router";import { db } from "@/db";import { verifyTurnstileToken } from "@redwoodjs/sdk/turnstile";
export async function startPasskeyRegistration( username: string, opts?: HandlerOptions,) { const { headers, env } = opts!;
const options = await generateRegistrationOptions({ rpName: env.APP_NAME, rpID: env.RP_ID, 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 finishPasskeyRegistration( username: string, registration: RegistrationResponseJSON, turnstileToken: string, opts?: HandlerOptions,) { const { request, headers, env } = opts!;
if ( !(await verifyTurnstileToken({ token: turnstileToken, secretKey: env.TURNSTILE_SECRET_KEY, })) ) { return false; }
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.RP_ID, });
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 startPasskeyLogin(opts?: HandlerOptions) { const { headers, env } = opts!;
const options = await generateAuthenticationOptions({ rpID: env.RP_ID, userVerification: "preferred", allowCredentials: [], });
await sessions.save(headers, { challenge: options.challenge });
return options;}
export async function finishPasskeyLogin(2 collapsed lines
login: AuthenticationResponseJSON, opts?: HandlerOptions,) {3 collapsed lines
const { request, headers, env } = opts!; const { origin } = new URL(request.url);
const session = await sessions.load(request); const challenge = session?.challenge;51 collapsed lines
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.RP_ID, 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";import { useTurnstile } from "@redwoodjs/sdk/turnstile";
// >>> Replace this with your own Cloudflare Turnstile site keyconst TURNSTILE_SITE_KEY = "1x00000000000000000000AA";
export function Login() {22 collapsed lines
const [username, setUsername] = useState(""); const [result, setResult] = useState(""); const [isPending, startTransition] = useTransition(); const turnstile = useTurnstile(TURNSTILE_SITE_KEY);
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 });
const turnstileToken = await turnstile.challenge();
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration( username, registration, turnstileToken, );
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };27 collapsed lines
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <> <div ref={turnstile.ref} /> <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";import { useTurnstile } from "@redwoodjs/sdk/turnstile";
// >>> Replace this with your own Cloudflare Turnstile site keyconst TURNSTILE_SITE_KEY = "1x00000000000000000000AA";1 collapsed line
export function Login() {3 collapsed lines
const [username, setUsername] = useState(""); const [result, setResult] = useState(""); const [isPending, startTransition] = useTransition(); const turnstile = useTurnstile(TURNSTILE_SITE_KEY);18 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 () => {6 collapsed lines
// 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 });
const turnstileToken = await turnstile.challenge();
// 3. Give the signed challenge to the worker to finish the registration process const success = await finishPasskeyRegistration( username, registration, turnstileToken, );6 collapsed lines
if (!success) { setResult("Registration failed"); } else { setResult("Registration successful!"); } };9 collapsed lines
const handlePerformPasskeyLogin = () => { startTransition(() => void passkeyLogin()); };
const handlePerformPasskeyRegister = () => { startTransition(() => void passkeyRegister()); };
return ( <> <div ref={turnstile.ref} />13 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>} </> );}
38 collapsed lines
"use server";import { generateRegistrationOptions, generateAuthenticationOptions, verifyRegistrationResponse, verifyAuthenticationResponse, RegistrationResponseJSON, AuthenticationResponseJSON,} from "@simplewebauthn/server";
import { sessions } from "@/session/store";import { HandlerOptions } from "@redwoodjs/sdk/router";import { db } from "@/db";import { verifyTurnstileToken } from "@redwoodjs/sdk/turnstile";
export async function startPasskeyRegistration( username: string, opts?: HandlerOptions,) { const { headers, env } = opts!;
const options = await generateRegistrationOptions({ rpName: env.APP_NAME, rpID: env.RP_ID, 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 finishPasskeyRegistration(4 collapsed lines
username: string, registration: RegistrationResponseJSON, turnstileToken: string, opts?: HandlerOptions,) {2 collapsed lines
const { request, headers, env } = opts!;
if ( !(await verifyTurnstileToken({ token: turnstileToken, secretKey: env.TURNSTILE_SECRET_KEY, })) ) { return false; }39 collapsed lines
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.RP_ID, });
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;}82 collapsed lines
export async function startPasskeyLogin(opts?: HandlerOptions) { const { headers, env } = opts!;
const options = await generateAuthenticationOptions({ rpID: env.RP_ID, userVerification: "preferred", allowCredentials: [], });
await sessions.save(headers, { challenge: options.challenge });
return options;}
export async function finishPasskeyLogin( login: AuthenticationResponseJSON, opts?: HandlerOptions,) { const { request, headers, env } = opts!; 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.RP_ID, 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;}