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 install

Step 2: Install HelioRim SDKs (2 min)

pnpm add @heliorim/sdk @heliorim/sdk-react @heliorim/sdk-cloudflare

Step 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=preview
wrangler.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 production

Step 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 --remote

Validation 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.