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-nextStep 2: Install HelioRim SDKs (2 min)
pnpm add @heliorim/sdk @heliorim/sdk-cloudflareStep 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.devStep 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-pagesor native edge adapter. - Run
pnpm lint && pnpm typecheck && pnpm buildbefore 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_URLis reachable. - Passkey prompt missing: require HTTPS and modern browser.
- Cookie not set: confirm API route returns Set-Cookie and domain matches.