Passkey Auth with React Router v7 on Cloudflare Workers
Time: 30 minutes · Difficulty: Beginner · Goal: SSR-ready passkey auth with edge JWT validation (no Node polyfills).
Outcome
- HelioRim passkey registration + login
- HttpOnly JWT cookie validated in loaders
- Protected routes via edge-native token verification
Guardrails
- Web Crypto only (Ed25519), no Buffer/nodejs_compat
- JWT 3-part tokens, HttpOnly cookie, SameSite=Lax
- Deploy with
wrangler --remote
Prerequisites
- Node.js 20+, pnpm, wrangler (`pnpm add -g wrangler`)
- Cloudflare account (Workers, not Pages)
- HelioRim tenant + app from auth.heliorim.dev
- Passkey-capable device and modern browser
Step 1: Scaffold React Router v7 app (5 min)
pnpm dlx create-react-router@latest heliorim-router
cd heliorim-router
pnpm installStep 2: Install HelioRim SDKs (2 min)
pnpm add @heliorim/sdk @heliorim/sdk-react @heliorim/sdk-cloudflareStep 3: Configure env + wrangler (3 min)
.dev.vars
HELIORIM_TENANT_ID=your-tenant-id
HELIORIM_APP_ID=your-app-id
HELIORIM_API_KEY=your-api-key # use wrangler secret put for preview/prod
HELIORIM_API_URL=https://auth-api.heliorim.dev
HELIORIM_JWKS_URL=https://auth-api.heliorim.dev/.well-known/jwks.json
ENVIRONMENT=previewwrangler.toml (core)
name = "heliorim-router"
main = "workers/app.ts"
compatibility_date = "2025-01-01"
[vars]
HELIORIM_TENANT_ID = "${HELIORIM_TENANT_ID}"
HELIORIM_APP_ID = "${HELIORIM_APP_ID}"
HELIORIM_API_URL = "https://auth-api.heliorim.dev"
HELIORIM_JWKS_URL = "https://auth-api.heliorim.dev/.well-known/jwks.json"
ENVIRONMENT = "preview"
# Secrets (preview/prod):
# wrangler secret put HELIORIM_API_KEY --env preview
# wrangler secret put HELIORIM_API_KEY --env productionStep 4: Edge session helper (5 min)
Create a single server helper to validate JWTs with Web Crypto.
app/utils/heliorim.server.ts
import { JWTKeyManager } from "@heliorim/core/crypto";
import { validateSession } from "@heliorim/sdk-cloudflare";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
const keyManager = new JWTKeyManager();
let cached: { jwk: JsonWebKey; expiresAt: number } | null = null;
async function loadJwk(env: HelioRimEnv): Promise<JsonWebKey> {
const now = Date.now();
if (cached && cached.expiresAt > now) return cached.jwk;
const res = await fetch(env.HELIORIM_JWKS_URL, { headers: { accept: "application/json" } });
if (!res.ok) throw new Error("Unable to fetch JWKS");
const body = (await res.json()) as { keys?: JsonWebKey[] };
const key = (body.keys ?? []).find((k) => k.kty === "OKP" && k.crv === "Ed25519");
if (!key) throw new Error("No Ed25519 key in JWKS");
cached = { jwk: key, expiresAt: now + 15 * 60 * 1000 };
return key;
}
export async function requireSession(request: Request, env: HelioRimEnv) {
const jwk = await loadJwk(env);
return validateSession(request, {
jwtPublicKey: jwk,
expectedAudience: env.HELIORIM_APP_ID ?? "heliorim.dev",
expectedIssuer: "auth.heliorim.dev",
});
}
export async function importPublicKey(env: HelioRimEnv) {
const jwk = await loadJwk(env);
return keyManager.importPublicKey(jwk);
}Step 5: Expose env to routes (2 min)
In your entry server, pass Cloudflare env through loadContext.
workers/app.ts
import { createRequestHandler } from "react-router";
export default {
fetch(request: Request, env: any, ctx: ExecutionContext) {
return createRequestHandler(
() => import("virtual:react-router/server-build"),
process.env.NODE_ENV,
)(request, { cloudflare: { env, ctx } });
},
};Step 6: HelioRim provider (5 min)
Use the React SDK to handle passkey flows client-side and persist the token to the server via an action.
app/components/HelioRimProvider.tsx
import { createContext, useContext, useEffect, useState } from "react";
import { createAuth, type Session, type HelioRimOptions } from "@heliorim/sdk";
interface HelioContext {
session: Session | null;
ready: boolean;
registerPasskey: (input: { email: string; displayName: string }) => Promise<void>;
loginPasskey: (input: { email?: string }) => Promise<void>;
}
const Context = createContext<HelioContext | null>(null);
async function persistSession(token: string) {
await fetch("/auth/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
}
export function HelioRimProvider({
children,
config,
}: {
children: React.ReactNode;
config: HelioRimOptions;
}) {
const [session, setSession] = useState<Session | null>(null);
const [client, setClient] = useState<Awaited<ReturnType<typeof createAuth>> | null>(null);
useEffect(() => {
createAuth(config).then((sdk) => {
setClient(sdk);
setSession(sdk.getSession());
});
}, [config]);
const registerPasskey = async ({ email, displayName }: { email: string; displayName: string }) => {
if (!client) throw new Error("HelioRim not ready");
const next = await client.register({ email: email, displayName, authenticatorAttachment: "platform" });
await persistSession(next.accessToken);
setSession(next);
};
const loginPasskey = async ({ email }: { email?: string }) => {
if (!client) throw new Error("HelioRim not ready");
const next = await client.authenticate({ email: email, conditionalUI: !email });
await persistSession(next.accessToken);
setSession(next);
};
return (
<Context.Provider
value={{
session,
ready: Boolean(client),
registerPasskey,
loginPasskey,
}}
>
{children}
</Context.Provider>
);
}
export function useHelioRim() {
const value = useContext(Context);
if (!value) throw new Error("useHelioRim must be used within HelioRimProvider");
return value;
}Step 7: Register + Login routes (10 min)
app/routes/register.tsx
import { FormEvent, useState } from "react";
import { useHelioRim } from "~/components/HelioRimProvider";
import { useNavigate, useOutletContext } from "react-router";
import type { HelioRimOptions } from "@heliorim/sdk";
import { HelioRimProvider } from "~/components/HelioRimProvider";
function RegisterForm() {
const { registerPasskey, ready } = useHelioRim();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
await registerPasskey({ email, displayName: name });
navigate("/dashboard");
};
return (
<form onSubmit={submit} className="space-y-4">
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
<input className="input" value={name} onChange={(e) => setName(e.target.value)} type="text" required />
<button className="btn-primary w-full" disabled={!ready || busy} type="submit">
{busy ? "Creating..." : "Create passkey"}
</button>
</form>
);
}
export default function RegisterRoute() {
const env = useOutletContext<HelioRimOptions & { tenantId?: string }>();
const config: HelioRimOptions = {
appId: env.appId,
apiEndpoint: env.apiEndpoint,
tenantId: env.tenantId,
environment: env.environment ?? "production",
};
return (
<HelioRimProvider config={config}>
<div className="card">
<h1>Create your passkey</h1>
<RegisterForm />
</div>
</HelioRimProvider>
);
}app/routes/login.tsx
import { FormEvent, useState } from "react";
import { useHelioRim } from "~/components/HelioRimProvider";
import { useNavigate } from "react-router";
export default function LoginRoute() {
const { loginPasskey, ready } = useHelioRim();
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
await loginPasskey({ email: email || undefined });
navigate("/dashboard");
};
return (
<div className="card">
<h1>Sign in</h1>
<form onSubmit={submit} className="space-y-4">
<input
className="input"
placeholder="Email (optional when autofill available)"
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<button className="btn-primary w-full" disabled={!ready || busy} type="submit">
{busy ? "Authenticating..." : "Login with passkey"}
</button>
</form>
</div>
);
}Step 8: Server session guard (5 min)
Protect loaders/actions with edge JWT validation.
app/routes/dashboard.tsx
import { json, redirect, type LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { requireSession } from "~/utils/heliorim.server";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
export async function loader({ request, context }: LoaderFunctionArgs) {
const env = (context as any).cloudflare.env as HelioRimEnv;
const session = await requireSession(request, env);
if (!session) throw redirect("/login");
return json({ session });
}
export default function Dashboard() {
const { session } = useLoaderData<typeof loader>();
return (
<div className="card space-y-2">
<h1>Welcome</h1>
<p>User: {session.email}</p>
<p>Tenant: {session.tenantId}</p>
</div>
);
}Step 9: Persist HttpOnly cookie (edge action)
app/routes/auth.session.tsx
import { json, type ActionFunctionArgs } from "react-router";
import { createTokenCookie } from "@heliorim/core/crypto";
import { importPublicKey } from "~/utils/heliorim.server";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
export async function action({ request, context }: ActionFunctionArgs) {
const env = (context as any).cloudflare.env as HelioRimEnv;
const { token } = (await request.json()) as { token?: string };
if (!token) return json({ error: "Missing token" }, { status: 400 });
// Optional: verify token before setting cookie
const publicKey = await importPublicKey(env);
// createTokenCookie is format-only; upstream validation is handled in loaders
const cookie = createTokenCookie(token, {
name: "heliorim_auth_token",
sameSite: "lax",
secure: true,
httpOnly: true,
path: "/",
});
return json({ ok: true }, { headers: { "Set-Cookie": cookie } });
}Step 10: Deploy to Cloudflare Workers (5 min)
pnpm build
wrangler deploy --env preview --remote
wrangler deploy --env production --remoteValidation Checklist
- pnpm typecheck && pnpm lint (no warnings)
- Register + login flow works on preview
- Dashboard loader redirects when no cookie
- HttpOnly cookie present, SameSite=Lax, Secure
Troubleshooting
- Missing passkey prompt: ensure HTTPS + WebAuthn support.
- 401 in loader: confirm JWKS URL reachable and cookie set.
- CSR mismatch: ensure providers render only on client sections.