Passkey Auth with Next.js (App Router) on the Edge

Time: 30 minutes · Difficulty: Intermediate · Goal: App Router flow with edge runtime, HttpOnly JWT cookie, and passkey UX.

Outcome

  • Edge runtime pages (no Node polyfills)
  • Passkey register/login using HelioRim SDK
  • Middleware protection for authenticated routes

Guardrails

  • Use export const runtime = "edge" in routes
  • JWT (EdDSA) only; Web Crypto APIs, no Buffer
  • HttpOnly cookie set by API route; SameSite=Lax

Prerequisites

  • Node.js 20+, pnpm
  • Next.js 15+ (App Router) on an edge-capable host
  • HelioRim tenant + app from auth.heliorim.dev
  • Passkey-capable device and modern browser

Step 1: Create project (5 min)

pnpm create next-app@latest heliorim-next --use-pnpm --eslint --ts --app
cd heliorim-next

Step 2: Install HelioRim SDKs (2 min)

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

Step 3: Env configuration (3 min)

.env.local
HELIORIM_TENANT_ID=your-tenant-id
HELIORIM_APP_ID=your-app-id
HELIORIM_API_KEY=your-api-key        # keep secret (use Vercel/Cloudflare secrets)
HELIORIM_API_URL=https://auth-api.heliorim.dev
HELIORIM_JWKS_URL=https://auth-api.heliorim.dev/.well-known/jwks.json
NEXT_PUBLIC_HELIORIM_APP_ID=your-app-id
NEXT_PUBLIC_HELIORIM_API_URL=https://auth-api.heliorim.dev

Step 4: Edge token helper (5 min)

Reusable server utility for JWT verification on the edge.

app/lib/heliorim.ts
import { JWTKeyManager, createTokenCookie } from "@heliorim/core/crypto";
import { validateSession } from "@heliorim/sdk-cloudflare";

const keyManager = new JWTKeyManager();
let cache: { jwk: JsonWebKey; expiresAt: number } | null = null;

async function loadJwk(jwksUrl: string): Promise<JsonWebKey> {
  const now = Date.now();
  if (cache && cache.expiresAt > now) return cache.jwk;
  const res = await fetch(jwksUrl, { 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");
  cache = { jwk: key, expiresAt: now + 15 * 60 * 1000 };
  return key;
}

export async function requireSession(request: Request) {
  const jwk = await loadJwk(process.env.HELIORIM_JWKS_URL!);
  return validateSession(request, {
    jwtPublicKey: jwk,
    expectedAudience: process.env.HELIORIM_APP_ID ?? "heliorim.dev",
    expectedIssuer: "auth.heliorim.dev",
  });
}

export async function buildSessionCookie(token: string) {
  return createTokenCookie(token, {
    name: "heliorim_auth_token",
    sameSite: "lax",
    secure: true,
    httpOnly: true,
    path: "/",
  });
}

export async function importPublicKey() {
  const jwk = await loadJwk(process.env.HELIORIM_JWKS_URL!);
  return keyManager.importPublicKey(jwk);
}

Step 5: Persist session cookie (API route)

app/api/heliorim/session/route.ts
export const runtime = "edge";

import { NextResponse } from "next/server";
import { buildSessionCookie, importPublicKey } from "@/app/lib/heliorim";

export async function POST(request: Request) {
  const { token } = (await request.json()) as { token?: string };
  if (!token) {
    return NextResponse.json({ error: "Missing token" }, { status: 400 });
  }

  // Optional: decode/validate using importPublicKey if you need pre-checks here
  const cookie = await buildSessionCookie(token);
  const response = NextResponse.json({ ok: true });
  response.headers.append("Set-Cookie", cookie);
  return response;
}

Step 6: Middleware protection

middleware.ts
import { NextResponse } from "next/server";
import { requireSession } from "@/app/lib/heliorim";

export const config = {
  matcher: ["/dashboard/:path*", "/api/secure/:path*"],
};

export async function middleware(request: Request) {
  const session = await requireSession(request);
  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

Step 7: Client passkey hooks (App Router)

app/(auth)/register/page.tsx
export const runtime = "edge";
"use client";

import { useState, FormEvent } from "react";
import { createAuth } from "@heliorim/sdk";

export default function RegisterPage() {
  const [client, setClient] = useState<Awaited<ReturnType<typeof createAuth>> | null>(null);
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);

  if (!client) {
    createAuth({
      appId: process.env.NEXT_PUBLIC_HELIORIM_APP_ID!,
      apiEndpoint: process.env.NEXT_PUBLIC_HELIORIM_API_URL,
      environment: "production",
    }).then(setClient);
  }

  const submit = async (event: FormEvent) => {
    event.preventDefault();
    if (!client) return;
    setBusy(true);
    const session = await client.register({
      email: email,
      displayName: name,
      authenticatorAttachment: "platform",
    });
    await fetch("/api/heliorim/session", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token: session.accessToken }),
    });
    window.location.href = "/dashboard";
  };

  return (
    <div className="card space-y-4">
      <h1>Create your passkey</h1>
      <form onSubmit={submit} className="space-y-3">
        <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={!client || busy} type="submit">
          {busy ? "Creating..." : "Create passkey"}
        </button>
      </form>
    </div>
  );
}
app/(auth)/login/page.tsx
export const runtime = "edge";
"use client";

import { useState, FormEvent } from "react";
import { createAuth } from "@heliorim/sdk";

export default function LoginPage() {
  const [client, setClient] = useState<Awaited<ReturnType<typeof createAuth>> | null>(null);
  const [email, setEmail] = useState("");
  const [busy, setBusy] = useState(false);

  if (!client) {
    createAuth({
      appId: process.env.NEXT_PUBLIC_HELIORIM_APP_ID!,
      apiEndpoint: process.env.NEXT_PUBLIC_HELIORIM_API_URL,
      environment: "production",
    }).then(setClient);
  }

  const submit = async (event: FormEvent) => {
    event.preventDefault();
    if (!client) return;
    setBusy(true);
    const session = await client.authenticate({ email: email || undefined, conditionalUI: !email });
    await fetch("/api/heliorim/session", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token: session.accessToken }),
    });
    window.location.href = "/dashboard";
  };

  return (
    <div className="card space-y-4">
      <h1>Sign in</h1>
      <form onSubmit={submit} className="space-y-3">
        <input
          className="input"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Email (optional when autofill available)"
          type="email"
        />
        <button className="btn-primary w-full" disabled={!client || busy} type="submit">
          {busy ? "Authenticating..." : "Login with passkey"}
        </button>
      </form>
    </div>
  );
}

Step 8: Dashboard (edge, server component)

app/dashboard/page.tsx
export const runtime = "edge";

import { cookies } from "next/headers";
import { requireSession } from "@/app/lib/heliorim";

export default async function DashboardPage() {
  const cookieHeader = cookies().toString();
  const session = await requireSession(
    new Request("https://app/dashboard", {
      headers: { cookie: cookieHeader },
    }),
  );

  if (!session) {
    return null; // Middleware already redirected unauthenticated users
  }

  return (
    <div className="card space-y-2">
      <h1>Welcome</h1>
      <p>User: {session.email}</p>
      <p>Tenant: {session.tenantId}</p>
    </div>
  );
}

Step 9: Deploy (edge)

  • Vercel: set project to Edge Runtime by default.
  • Cloudflare Pages/Workers: use next-on-pages or native edge adapter.
  • Run pnpm lint && pnpm typecheck && pnpm build before deploy.

Validation Checklist

  • pnpm typecheck && pnpm lint pass locally
  • Register + login flows succeed; HttpOnly cookie set
  • Middleware redirects unauthenticated users
  • No Node globals used (Buffer/crypto)

Troubleshooting

  • Ensure runtime = "edge" on routes using Web Crypto.
  • JWKS fetch errors: verify HELIORIM_JWKS_URL is reachable.
  • Passkey prompt missing: require HTTPS and modern browser.
  • Cookie not set: confirm API route returns Set-Cookie and domain matches.