Skip to content
4th April 2025: This is a preview, whilst production-ready, it means some APIs might change

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:

Terminal window
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:

Terminal window
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:

Terminal window
# Generate a 32-byte random key and encode it as base64
openssl rand -base64 32

Then set this key as a Cloudflare secret:

Terminal window
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:

  1. Visit Cloudflare Turnstile Dashboard.

  2. Create a new Turnstile widget:

    • Set Widget Mode to invisible
    • Add your application’s hostname to Allowed hostnames, e.g., my-app.example.com.
  3. Copy your Site Key into your application’s LoginPage.tsx:

LoginPage.tsx
const TURNSTILE_SITE_KEY = "<YOUR_SITE_KEY>";
  1. Set your Turnstile Secret Key via Cloudflare secrets for production:
Terminal window
npx wrangler secret put TURNSTILE_SECRET_KEY
  1. 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:

  1. Check for a session_id cookie.
  2. Verify that the session ID is valid by checking its signature - this lets us be sure it was us that issued it
  3. If it’s valid, load the session data from the Durable Object.
  4. If there’s an active session, we pull the user ID from it and load the user from the database (D1 + Prisma).
src/worker.tsx
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

  1. The user clicks Login.
  2. The frontend calls a server action to get a WebAuthn challenge.
  3. The challenge is stored in the session store using sessions.save().
  4. The user’s authenticator signs the challenge.
  5. The signed challenge is sent back to the server and verified.
  6. If successful, the session is updated with the user ID.
src/app/pages/user/Login.tsx
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>}
</>
);
}
src/app/pages/user/functions.ts
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:

src/app/pages/user/Login.tsx
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

  1. The Turnstile client script is loaded on the page.
  2. The frontend calls useTurnstile() to generate a challenge token.
  3. The token is sent to the backend along with the WebAuthn data.
  4. The backend verifies the token using verifyTurnstileToken() before completing registration.
src/app/pages/user/Login.tsx
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>}
</>
);
}
src/app/pages/user/functions.ts
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;
}