Add Passkey Auth to Remix on Cloudflare Workers
Time: 30 minutes · Difficulty: Beginner · Target: production-ready passkey auth with SSR-safe session verification.
Outcome
- Passkey registration + login with HelioRim SDK
- HttpOnly session cookie validated at the edge
- Protected Remix loaders/actions on Cloudflare Workers
Success Metrics
- < 30 minutes from create-remix to deploy
- Zero Node polyfills (`nodejs_compat` not required)
- Ed25519 JWT verification via Web Crypto only
Prerequisites
- Node.js 20+, pnpm, and wrangler installed (`pnpm add -g wrangler`)
- Cloudflare account with an empty Worker (no Pages)
- HelioRim account with tenant + app (auth.heliorim.dev)
- Passkey-capable device (WebAuthn) and modern browser
Step 1: Create the Remix project (5 min)
Scaffold the Workers-native template and install dependencies.
pnpm dlx create-remix@latest heliorim-remix -- --template remix-run/remix/templates/cloudflare-workers
cd heliorim-remix
pnpm installStep 2: Install HelioRim SDKs (2 min)
Use the browser SDK for passkeys and the Cloudflare helper for JWT validation in loaders/actions.
pnpm add @heliorim/sdk @heliorim/sdk-cloudflare
pnpm add -D @cloudflare/workers-typesStep 3: Configure environment + wrangler (3 min)
Define required variables for dev and production.
HELIORIM_TENANT_ID=your-tenant-id
HELIORIM_APP_ID=your-app-id
HELIORIM_API_KEY=your-api-key # use `wrangler secret put HELIORIM_API_KEY` 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
SESSION_SECRET=generate-a-random-stringname = "heliorim-remix"
main = "server.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"
# Keep HELIORIM_API_KEY in Secrets Store:
# wrangler secret put HELIORIM_API_KEY --env preview
# wrangler secret put HELIORIM_API_KEY --env productionStep 4: Edge session utilities (5 min)
Create a single server utility to cache JWKS, validate JWTs with Web Crypto, and mint the HttpOnly session cookie.
import { createTokenCookie, JWTKeyManager } from "@heliorim/core/crypto";
import { validateSession } from "@heliorim/sdk-cloudflare";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
const keyManager = new JWTKeyManager();
const jwkCache: { key: JsonWebKey | null; expiresAt: number } = {
key: null,
expiresAt: 0,
};
async function loadJwk(env: HelioRimEnv): Promise<JsonWebKey> {
if (jwkCache.key && jwkCache.expiresAt > Date.now()) {
return jwkCache.key;
}
const response = await fetch(env.HELIORIM_JWKS_URL, {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Response("Unable to load JWKS", { status: 502 });
}
const body = (await response.json()) as { keys?: JsonWebKey[] };
const ed25519 = (body.keys ?? []).find(
(key) => key.kty === "OKP" && key.crv === "Ed25519",
);
if (!ed25519) {
throw new Response("No Ed25519 key in JWKS", { status: 500 });
}
jwkCache.key = ed25519;
jwkCache.expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes
return ed25519;
}
export async function requireHelioSession(
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 validateTokenString(token: string, env: HelioRimEnv) {
const jwk = await loadJwk(env);
const syntheticRequest = new Request("https://heliorim.dev/session", {
headers: { Authorization: `Bearer ${token}` },
});
return validateSession(syntheticRequest, {
jwtPublicKey: jwk,
expectedAudience: env.HELIORIM_APP_ID ?? "heliorim.dev",
expectedIssuer: "auth.heliorim.dev",
});
}
export function buildSessionCookie(token: string) {
return createTokenCookie(token, {
name: "heliorim_auth_token",
maxAge: 60 * 60 * 24 * 7,
sameSite: "lax",
path: "/",
});
}
export async function importPublicKey(env: HelioRimEnv) {
const jwk = await loadJwk(env);
return keyManager.importPublicKey(jwk);
}Step 5: Expose env to the client (2 min)
Remix loaders run on Workers; forward only the public config needed to initialize the browser SDK.
import { json, type LinksFunction, type LoaderFunctionArgs } from "@remix-run/cloudflare";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
export const loader = ({ context }: LoaderFunctionArgs) => {
const env = context.cloudflare.env;
if (!env.HELIORIM_APP_ID) {
throw new Response("Missing HELIORIM_APP_ID", { status: 500 });
}
return json({
env: {
HELIORIM_APP_ID: env.HELIORIM_APP_ID,
HELIORIM_API_URL: env.HELIORIM_API_URL ?? "https://auth-api.heliorim.dev",
HELIORIM_TENANT_ID: env.HELIORIM_TENANT_ID,
ENVIRONMENT: env.ENVIRONMENT ?? "production",
},
});
};
export default function App() {
const { env } = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet context={env} />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)};`,
}}
/>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Step 6: Client-side HelioRim provider (5 min)
Initialize the SDK once, capture the session, and persist the JWT as an HttpOnly cookie via a Remix action.
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { createAuth, type HelioRimOptions, type Session } from "@heliorim/sdk";
interface HelioRimContextValue {
session: Session | null;
isReady: boolean;
error?: Error;
registerWithPasskey: (input: { email: string; displayName: string }) => Promise<Session>;
loginWithPasskey: (input: { email?: string }) => Promise<Session>;
}
const HelioRimContext = createContext<HelioRimContextValue | undefined>(undefined);
async function persistSession(token: string) {
const response = await fetch("/auth/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new Error("Failed to persist session");
}
}
export function HelioRimProvider({
children,
config,
}: {
children: React.ReactNode;
config: HelioRimOptions;
}) {
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
const [client, setClient] = useState<Awaited<ReturnType<typeof createAuth>> | null>(null);
useEffect(() => {
createAuth(config)
.then((instance) => {
setClient(instance);
setSession(instance.getSession());
})
.catch((err: Error) => setError(err));
}, [config]);
const value = useMemo<HelioRimContextValue>(() => {
return {
session,
isReady: Boolean(client),
error,
registerWithPasskey: async ({ email, displayName }) => {
if (!client) {
throw new Error("HelioRim is not ready yet");
}
const result = await client.register({
email: email,
displayName,
authenticatorAttachment: "platform",
});
await persistSession(result.accessToken);
setSession(result);
return result;
},
loginWithPasskey: async ({ email }) => {
if (!client) {
throw new Error("HelioRim is not ready yet");
}
const result = await client.authenticate({
email: email,
conditionalUI: !email,
});
await persistSession(result.accessToken);
setSession(result);
return result;
},
};
}, [client, error, session]);
return <HelioRimContext.Provider value={value}>{children}</HelioRimContext.Provider>;
}
export function useHelioRim() {
const context = useContext(HelioRimContext);
if (!context) {
throw new Error("useHelioRim must be used within HelioRimProvider");
}
return context;
}Step 7: Registration route (5 min)
Client-only passkey ceremony; the server action only handles the JWT cookie minting.
import { FormEvent, useState } from "react";
import { HelioRimProvider, useHelioRim } from "~/components/HelioRimProvider";
import type { HelioRimOptions } from "@heliorim/sdk";
import { useOutletContext, useNavigate } from "@remix-run/react";
function RegisterForm() {
const navigate = useNavigate();
const { registerWithPasskey, isReady, error } = useHelioRim();
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [busy, setBusy] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await registerWithPasskey({ email, displayName });
navigate("/dashboard");
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="input"
placeholder="name@example.com"
/>
<input
type="text"
required
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="input"
placeholder="Display name"
/>
<button type="submit" disabled={!isReady || busy} className="btn-primary w-full">
{busy ? "Creating passkey…" : "Create passkey"}
</button>
{error ? <p className="text-red-500 text-sm">{error.message}</p> : null}
</form>
);
}
export default function RegisterRoute() {
const env = useOutletContext<{
HELIORIM_APP_ID: string;
HELIORIM_API_URL: string;
HELIORIM_TENANT_ID: string;
ENVIRONMENT: string;
}>();
const config: HelioRimOptions = {
appId: env.HELIORIM_APP_ID,
apiEndpoint: env.HELIORIM_API_URL,
tenantId: env.HELIORIM_TENANT_ID,
environment: env.ENVIRONMENT as "production" | "preview" | "development",
};
return (
<HelioRimProvider config={config}>
<div className="card">
<h1>Create your passkey</h1>
<RegisterForm />
</div>
</HelioRimProvider>
);
}Step 8: Login route (5 min)
Supports autofill/conditional UI when the user email is omitted.
import { FormEvent, useState } from "react";
import { HelioRimProvider, useHelioRim } from "~/components/HelioRimProvider";
import type { HelioRimOptions } from "@heliorim/sdk";
import { useNavigate, useOutletContext } from "@remix-run/react";
function LoginForm() {
const navigate = useNavigate();
const { loginWithPasskey, isReady } = useHelioRim();
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await loginWithPasskey({ email: email || undefined });
navigate("/dashboard");
} finally {
setBusy(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="input"
placeholder="Email (optional when autofill is available)"
/>
<button type="submit" disabled={!isReady || busy} className="btn-primary w-full">
{busy ? "Authenticating…" : "Login with passkey"}
</button>
</form>
);
}
export default function LoginRoute() {
const env = useOutletContext<{
HELIORIM_APP_ID: string;
HELIORIM_API_URL: string;
HELIORIM_TENANT_ID: string;
ENVIRONMENT: string;
}>();
const config: HelioRimOptions = {
appId: env.HELIORIM_APP_ID,
apiEndpoint: env.HELIORIM_API_URL,
tenantId: env.HELIORIM_TENANT_ID,
environment: env.ENVIRONMENT as "production" | "preview" | "development",
};
return (
<HelioRimProvider config={config}>
<div className="card">
<h1>Sign in</h1>
<LoginForm />
</div>
</HelioRimProvider>
);
}Step 9: Server session guard + API protection (5 min)
Protect both pages and API routes with JWT verification on the edge.
import { json, redirect, type LoaderFunctionArgs } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import { requireHelioSession } from "~/utils/heliorim.server";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
export async function loader({ request, context }: LoaderFunctionArgs) {
const env = context.cloudflare.env as HelioRimEnv;
const session = await requireHelioSession(request, env);
if (!session) {
throw redirect("/login");
}
return json({ session });
}
export default function DashboardRoute() {
const { session } = useLoaderData<typeof loader>();
return (
<div className="card space-y-3">
<h1>Welcome back</h1>
<p className="text-gray-600 dark:text-gray-300">
User: {session.email}
</p>
<p className="text-gray-600 dark:text-gray-300">
Tenant: {session.tenantId}
</p>
</div>
);
}import { json, type ActionFunctionArgs } from "@remix-run/cloudflare";
import { buildSessionCookie, validateTokenString } from "~/utils/heliorim.server";
import type { HelioRimEnv } from "@heliorim/sdk-cloudflare";
export async function action({ request, context }: ActionFunctionArgs) {
const env = context.cloudflare.env as HelioRimEnv;
const { token } = (await request.json()) as { token?: string };
if (!token) {
return json({ error: "Missing token" }, { status: 400 });
}
const session = await validateTokenString(token, env);
if (!session) {
return json({ error: "Invalid token" }, { status: 401 });
}
return json(
{ ok: true },
{
headers: {
"Set-Cookie": buildSessionCookie(token),
},
},
);
}Step 10: Deploy to Cloudflare Workers (5 min)
Run preview first, then production. Use --remote so the deploy uses edge KV/D1 resources.
pnpm build
wrangler deploy --env preview --remote
wrangler deploy --env production --remoteValidation Checklist
- pnpm lint --filter heliorim-remix (no warnings/errors)
- pnpm typecheck --filter heliorim-remix
- Register + login with passkey end-to-end on preview
- Dashboard loader redirects unauthenticated traffic
- API routes reject requests without `heliorim_auth_token`
Troubleshooting
- JWKS fetch fails: ensure
HELIORIM_JWKS_URLis reachable from the Worker and not blocked by firewall. - Passkey prompt missing: verify the site runs on HTTPS and WebAuthn is supported (
window.PublicKeyCredentialexists). - Session missing in loader: confirm
auth.sessionaction sets the HttpOnly cookie and the domain matches your site origin.