All posts
June 1, 2026·6 min read

Stateless Auth in Next.js App Router: HMAC Session Cookies Without a Third-Party Service

How I implemented session-based authentication using HMAC-signed cookies and edge middleware in Next.js App Router — no NextAuth, no Clerk, no Auth0.

Next.jsAuthSecurityApp RouterEdge

Most Next.js auth tutorials reach for NextAuth, Clerk, or Auth0 within the first paragraph. These are good tools. They're also abstractions that hide the mechanics of how authentication actually works, and for a project where I wanted full control over the session format, audit logging, and multi-tenancy model, they were the wrong fit.

This post covers the HMAC session cookie approach I built for the OMZ Tech Solutions client portal. It handles login, session verification at the edge, role-based access, account lockout, and audit logging — no third-party auth SDK in the hot path.

Why not JWTs

JWTs are stateless, which sounds like a feature. The server doesn't need to store anything. Any service that knows the secret can verify any token. But statelessness is also the problem.

You can't revoke a JWT before it expires. If a user logs out or their account is compromised, the token is still valid until the exp claim passes. You can build a blocklist, but then you've added statefulness back in — except now it's a distributed cache instead of a simple session table, and you've added complexity without gaining anything.

For an authenticated client portal with sensitive business data, I wanted a session model where logout is instant and definitive. HMAC-signed cookies give you that.

The session format

A session is a JSON payload signed with HMAC-SHA256:

// src/lib/auth/session.ts
 
interface SessionPayload {
  userId: string;
  tenantId: string;
  role: "admin" | "client";
  email: string;
  iat: number; // issued at
  exp: number; // expires at
}
 
export function signSession(payload: SessionPayload, secret: string): string {
  const data = JSON.stringify(payload);
  const encoded = Buffer.from(data).toString("base64url");
  const sig = createHmac("sha256", secret)
    .update(encoded)
    .digest("base64url");
  return `${encoded}.${sig}`;
}
 
export function verifySession(
  token: string,
  secret: string
): SessionPayload | null {
  const [encoded, sig] = token.split(".");
  if (!encoded || !sig) return null;
 
  const expectedSig = createHmac("sha256", secret)
    .update(encoded)
    .digest("base64url");
 
  // Constant-time comparison to prevent timing attacks
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
    return null;
  }
 
  try {
    const payload = JSON.parse(
      Buffer.from(encoded, "base64url").toString()
    ) as SessionPayload;
 
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;
    return payload;
  } catch {
    return null;
  }
}

The key details:

timingSafeEqual for signature comparison. A naive string comparison (sig === expectedSig) leaks information through timing — an attacker can measure how long the comparison takes to figure out how many characters match. timingSafeEqual runs in constant time regardless of how many bytes match.

Both iat and exp in the payload. iat (issued at) is useful for audit logs and for invalidating sessions issued before a password change. exp is checked on every verification.

Base64url encoding, not base64. Base64url uses - and _ instead of + and /, which makes the token safe to put in a cookie value without encoding.

Edge middleware for route protection

The session verification runs in middleware.ts at the edge — before the request reaches any server component or API route:

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
const PROTECTED_PREFIXES = ["/dashboard", "/admin", "/api/client"];
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isProtected = PROTECTED_PREFIXES.some((p) =>
    pathname.startsWith(p)
  );
 
  if (!isProtected) return NextResponse.next();
 
  const sessionCookie = request.cookies.get("omz_session")?.value;
  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  const session = verifySession(
    sessionCookie,
    process.env.AUTH_SECRET!
  );
 
  if (!session) {
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("omz_session");
    return response;
  }
 
  // Admin routes require admin role
  if (pathname.startsWith("/admin") && session.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
 
  // Forward session data as headers for server components
  const response = NextResponse.next();
  response.headers.set("x-session-user-id", session.userId);
  response.headers.set("x-session-tenant-id", session.tenantId);
  response.headers.set("x-session-role", session.role);
  return response;
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Forwarding session data as request headers lets server components read the session without re-verifying the cookie. The verification already happened at the edge — there's no need to do it again in every layout or page.

// In a server component
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headersList = await headers();
  const userId = headersList.get("x-session-user-id")!;
  const tenantId = headersList.get("x-session-tenant-id")!;
  // ...
}

Account lockout

Brute-force protection is a lockout counter stored in Supabase. The login flow reads the failure count before checking credentials:

export async function loginUser(
  email: string,
  password: string,
  ip: string
): Promise<LoginResult> {
  // 1. Check rate limit by IP
  const attempts = await getRecentAttempts(ip);
  if (attempts >= MAX_ATTEMPTS) {
    return { success: false, error: "Too many attempts. Try again later." };
  }
 
  // 2. Fetch user
  const user = await getUserByEmail(email);
  if (!user) {
    await recordFailedAttempt(ip, email);
    return { success: false, error: "Invalid credentials." };
  }
 
  // 3. Check account lockout
  if (user.failedLoginCount >= LOCKOUT_THRESHOLD) {
    return { success: false, error: "Account locked. Check your email." };
  }
 
  // 4. Verify password
  const valid = await verifyPassword(password, user.passwordHash);
  if (!valid) {
    await incrementFailedCount(user.id);
    await recordFailedAttempt(ip, email);
    return { success: false, error: "Invalid credentials." };
  }
 
  // 5. Reset failure count, issue session
  await resetFailedCount(user.id);
  const session = createSession(user);
  return { success: true, session };
}

Two important things here:

The error message is the same for "user not found" and "wrong password." Distinguishing between them leaks whether an email address is registered. Generic error messages are the correct security posture.

IP-based rate limiting and per-account lockout are independent. IP rate limiting stops automated attacks; per-account lockout handles targeted credential stuffing against a specific user. You need both.

Multi-tenancy with Supabase RLS

Each user belongs to a tenant. Supabase Row Level Security enforces tenant isolation at the database level:

-- Users can only see their own tenant's data
CREATE POLICY "tenant_isolation" ON websites
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Before any query, the application sets the tenant context:

const supabase = createClient(url, serviceKey);
await supabase.rpc("set_config", {
  setting: "app.tenant_id",
  value: tenantId,
});

This means even if there's a bug in the application layer that passes the wrong tenantId, the database will reject the query. Defense in depth: the application enforces tenancy, and the database enforces it again.

What this buys you

The whole auth layer is about 400 lines of TypeScript with no external dependencies beyond @supabase/supabase-js. It handles login, logout, session refresh, rate limiting, account lockout, audit logging, and multi-tenancy. The session verification at the edge costs roughly 0.5ms — fast enough that it doesn't meaningfully add to page load.

The absence of a third-party auth SDK means there are no webhooks to configure, no pricing tiers to worry about, and no data leaving your infrastructure. For a multi-tenant product with client data, that last point matters.

The tradeoff is maintenance. Auth bugs are security bugs. When you own the auth layer, you own the responsibility for keeping it correct. I wouldn't recommend this approach for a team without someone who has thought carefully about authentication security. But for a project where control is the point, it's exactly the right call.