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 install

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

Step 3: Configure environment + wrangler (3 min)

Define required variables for dev and production.

.dev.vars
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-string
wrangler.toml (core section)
name = "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 production

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

app/utils/heliorim.server.ts
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.

app/root.tsx
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.

app/components/HelioRimProvider.tsx
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.

app/routes/register.tsx
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.

app/routes/login.tsx
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.

app/routes/dashboard.tsx
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>
  );
}
app/routes/auth.session.tsx
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 --remote

Validation 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_URL is reachable from the Worker and not blocked by firewall.
  • Passkey prompt missing: verify the site runs on HTTPS and WebAuthn is supported (window.PublicKeyCredential exists).
  • Session missing in loader: confirm auth.session action sets the HttpOnly cookie and the domain matches your site origin.