13 KiB
for apps/web I would like to get away from using local storage for persisting avatar emojis / names / isActive and persisting this on a per guest session basis in a database. i want to use drizzle as orm and also for schema migrations. for instances where server components are impractical, I want to use tanstack query to interact with the api and for caching / invalidations and also mutations. i want to use nextauth + jwt for stateless and self contained sessions below I have pasted a generic guide that you can use as a pattern. i want to implement this step by step, testing thoroughly at high resolution checkpoints to ensure that at every step along the path we have a rock solid base. have a deep look at the source and propose a plan. keep in mind that this is a greenfield project with no users. we are aiming for excellent implementation and do not have to worry at all about preserving existign data or backwards compatibility
=== generic guide for implementing nextauth + jwt for guest session manageement with a clean path for upgrade to full user account ===
below is a guic guide for impda
- Stateless on the server (no DB writes just to identify a visitor).
- Self‑contained in the browser (HttpOnly cookies only — no localStorage).
- Easy upgrade from “guest” → full NextAuth user.
It uses two layers of identity:
- a tiny guest token (JWT) in an HttpOnly cookie you control;
- a normal NextAuth session (JWT strategy). On upgrade, the guest ID is carried into the NextAuth token so you can merge state.
Why this works
- NextAuth’s default session strategy is
"jwt", stored in a cookie as an encrypted JWE; this is stateless and requires no DB lookups. (Auth.js) - JWT cookies are size‑limited (~4 KB), so we store only a small guest ID and timestamps, not carts/settings. (If you need more than a few kilobytes, persist to your backend at upgrade time.) (NextAuth)
- With lazy initialization, NextAuth v5 lets you access the request inside your config (e.g., to read the guest cookie during callbacks). (Auth.js)
- Middleware can create/rotate HttpOnly cookies at the edge before your app runs. (Next.js)
The pattern (Next.js App Router + NextAuth v5)
0) Install & set secrets
npm i next-auth@beta jose
# .env
AUTH_SECRET=your-strong-random-secret
Auth.js v5 accepts
AUTH_SECRET(you can even rotate by passing an array of secrets). (Auth.js)
1) A tiny, signed “guest token” cookie
Create lib/guest-token.ts:
// lib/guest-token.ts
import { SignJWT, jwtVerify } from "jose";
const GUEST_COOKIE = "__Host-guest"; // secure + path=/, no Domain
export const GUEST_COOKIE_NAME = GUEST_COOKIE;
function getKey() {
const secret = process.env.AUTH_SECRET!;
return new TextEncoder().encode(secret);
}
export async function createGuestToken(
sid: string,
maxAgeSec = 60 * 60 * 24 * 30,
) {
const now = Math.floor(Date.now() / 1000);
return await new SignJWT({ typ: "guest", sid, iat: now })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(now + maxAgeSec)
.sign(getKey());
}
export async function verifyGuestToken(token: string) {
const { payload } = await jwtVerify(token, getKey());
if (payload.typ !== "guest" || typeof payload.sid !== "string")
throw new Error("bad guest token");
return {
sid: payload.sid as string,
iat: payload.iat as number,
exp: payload.exp as number,
};
}
We keep this payload tiny (just a stable sid) to respect cookie size limits. (NextAuth)
2) Middleware: ensure every visitor gets a guest token
Create middleware.ts (Edge‑compatible):
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createGuestToken, GUEST_COOKIE_NAME } from "./lib/guest-token";
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const existing = req.cookies.get(GUEST_COOKIE_NAME)?.value;
if (!existing) {
const sid = crypto.randomUUID();
const token = await createGuestToken(sid);
res.cookies.set({
name: GUEST_COOKIE_NAME,
value: token,
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/", // required for "__Host-" prefix
});
}
return res;
}
// Run on all HTML/app routes; skip static assets as you prefer.
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)"],
};
Reading/setting cookies in Middleware is supported; it runs before your route code. (Next.js)
3) NextAuth v5 config: carry guest → session
Create auth.ts (v5 “lazy init” so you can read the request/cookies):
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
// Add your real providers, e.g. GitHub/Google/Email
// import GitHub from "next-auth/providers/github";
import { verifyGuestToken, GUEST_COOKIE_NAME } from "@/lib/guest-token";
export type Role = "guest" | "user";
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
isGuest?: boolean;
guestId?: string | null;
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: Role;
guestId?: string | null; // previous guest sid for migrations
}
}
// This provider lets you treat guests as "signed-in" if you want a single code path.
// If you prefer to keep guests unauthenticated, you can omit this provider and just use the cookie.
const GuestProvider = Credentials({
id: "guest",
name: "Guest",
credentials: {},
async authorize() {
// Create a synthetic user ID. We still rely on the guest cookie as the stable ID.
return { id: `guest:${crypto.randomUUID()}`, name: "Guest" } as any;
},
});
export const { handlers, auth, signIn, signOut } = NextAuth((req) => ({
session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 30 },
// Include GuestProvider if you want `useSession()` to be "authenticated" for guests too.
providers: [GuestProvider /*, GitHub(), Google(), Email() ... */],
callbacks: {
// 1) When a user signs in (guest or real), shape the token
async jwt({ token, user, account, trigger }) {
if (trigger === "signIn" && account?.provider === "guest" && user) {
token.sub = user.id;
token.role = "guest";
// We’ll expose the actual guest sid through the cookie → session below
}
// On upgrade to a real account, capture the guest sid for migration
if (trigger === "signIn" && account && account.provider !== "guest") {
const raw = req?.cookies.get(GUEST_COOKIE_NAME)?.value;
if (raw) {
try {
const { sid } = await verifyGuestToken(raw);
token.guestId = sid; // carry through to session for merge step
} catch {}
}
token.role = "user";
}
return token;
},
// 2) What the client gets via useSession()/getSession()
async session({ session, token }) {
if (session.user && token.sub) session.user.id = token.sub;
session.isGuest = token.role === "guest";
// Expose the stable guest sid from the cookie (if present)
const raw = req?.cookies.get(GUEST_COOKIE_NAME)?.value;
session.guestId = null;
if (raw) {
try {
const { sid } = await verifyGuestToken(raw);
session.guestId = sid;
} catch {}
}
return session;
},
// 3) Optional: gate admin sections, etc., in middleware via `authorized`
authorized({ auth }) {
// Example: allow all visitors through; you can add role checks here.
return true;
},
},
// Cookie + JWT behavior (JWT/JWE cookie by default)
// - default JWE encryption; uses AUTH_SECRET
// - default cookie maxAge/rotation handled by NextAuth
}));
Notes
- NextAuth encrypts the JWT session cookie (JWE) using your
AUTH_SECRET; JWT is the default session strategy. (Auth.js) - We read the guest cookie inside callbacks thanks to v5 lazy initialization (
NextAuth(req => ({ ... }))). (Auth.js) - If you don’t want guests to appear “authenticated” in
useSession(), remove theGuestProvider. You’ll still havesession === nullfor guests but you can read theguestIdvia a tiny helper (below).
4) Unified “viewer” helper (server)
If you prefer not to include the GuestProvider, use this on the server:
// lib/viewer.ts
import { auth } from "@/auth";
import { cookies } from "next/headers";
import { verifyGuestToken, GUEST_COOKIE_NAME } from "@/lib/guest-token";
export async function getViewer() {
const session = await auth(); // NextAuth session or null
if (session) return { kind: "user" as const, session };
const raw = cookies().get(GUEST_COOKIE_NAME)?.value;
if (!raw) return { kind: "unknown" as const };
const { sid } = await verifyGuestToken(raw);
return { kind: "guest" as const, guestId: sid };
}
5) The upgrade path
When the guest authenticates with any real provider:
- JWT callback (above) copies the
guestIdfrom the cookie into the token. - Handle the actual merge in an event or the first request after sign‑in:
// in auth.ts (inside NextAuth config)
events: {
async signIn(msg) {
// For credentials providers, msg.user is the raw user.
// For OAuth/email, you'll have an adapter user in msg.user (if using an adapter)
// If you're JWT-only, you can read the guest cookie again here.
// Example (pseudocode):
// const guestId = ...read from cookie or msg...
// await mergeGuestDataIntoUser(guestId, msg.user.id)
},
},
Events are the right hook for one‑time merge/audit work. (Auth.js)
What to merge?
- Browser‑only state (e.g., a cart in another cookie/IndexedDB) → POST it to your API immediately after login, keyed by
session.guestId. - Server‑side, tie any anonymous trail you kept (e.g., telemetry keyed by
guestId) to the newuserId.
Client usage examples
- Guest‑aware UI (works both with and without
GuestProvider):
"use client";
import { useSession } from "next-auth/react";
export function UserBadge() {
const { data } = useSession();
if (data?.isGuest)
return <>Browsing as guest • id {data.guestId?.slice(0, 8)}…</>;
if (data?.user?.id) return <>Signed in • {data.user.name ?? data.user.id}</>;
return <>Loading…</>;
}
Security & pitfalls
- Keep the guest token tiny. Cookies have practical limits; NextAuth does cookie chunking, but you still pay in bandwidth; don’t stuff carts/preferences into JWTs. (NextAuth)
- Use the
__Host-prefix +Secure+HttpOnly+SameSite=Laxto harden your cookie (as shown). - Don’t rely on cookie names for NextAuth’s own session; use the framework helpers (
auth(),getSession(),getToken()), since cookie names can differ across versions/configs. NextAuth stores the session token in its Session Token cookie (JWE) and lets you customize names if needed. (NextAuth) - Role‑guard your APIs: treat
session.isGuest/token.role === "guest"as read‑only access.
Variants you can choose
A) “Guest as real session” (single code path)
- Keep
GuestProviderinproviders. On first visit, callawait signIn("guest", { redirect: false })client‑side to mint the NextAuth cookie (still no localStorage). - Everything reads from
useSession(); checksession.isGuest. - Upgrade with any provider; the
jwtcallback carriesguestIdacross.
B) “Guest as separate cookie” (leaner)
- Omit
GuestProvider. - Use
getViewer()server‑side (or a small/api/viewerendpoint) to get either{ kind: "guest", guestId }or a full session. - Upgrade flow is the same; NextAuth’s callbacks can still read the guest cookie at sign‑in time.
Both variants remain stateless and browser‑contained (HttpOnly cookies only). NextAuth’s JWT cookies are encrypted by default and rotated/extended by its helpers. (Auth.js)
What you get
- Stable guest identity from first pixel paint (middleware sets the cookie).
- A clean, reliable upgrade path: guest → user without losing context.
- No localStorage; everything is HttpOnly cookies + NextAuth JWT.
If you want, tell me whether you’d prefer Variant A or B for your app (and which auth providers you’ll use). I can tailor the config and add a tiny client hook for fetching viewer on the client without exposing tokens.