- I use Cursor + Claude to generate auth code, then I try to break it.
- I run a tiny Node script to fuzz cookies + headers.
- I lock session handling into one file. No scattered reads.
- I add 3 tests that catch 80% of my dumb mistakes.
Context
I ship small SaaS apps. Usually fast. Usually solo.
Auth is where I bleed time. Not because OAuth is hard. Because I miss boring edge cases.
Cursor + Claude help me write the first draft. Fast. But the first draft lies. It’ll “work” in the happy path. Then I open a second tab, log out, and something still looks logged in.
Spent 4 hours last month on a “random logout” bug. Most of it was wrong. The root cause: I was reading the session in 3 different places, each with slightly different assumptions.
So I switched to a workflow: generate → centralize → fuzz → test. Same steps every time.
1) I force all session reads through one function
If I read cookies in five files, I’m done.
One module. One exported function. Everything else calls that.
In Next.js App Router, I do it in lib/session.ts. Server-only.
// lib/session.ts
import { cookies, headers } from "next/headers";
export type Session = {
userId: string;
email: string;
roles: string[];
expiresAt: number; // unix ms
};
// Example: cookie contains base64url(JSON). Swap with real signed token.
function base64UrlToString(input: string) {
const pad = "=".repeat((4 - (input.length % 4)) % 4);
const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}
export function getSession(): Session | null {
// Single source of truth.
const token = cookies().get("session")?.value;
if (!token) return null;
try {
const json = base64UrlToString(token);
const parsed = JSON.parse(json) as Session;
// Hard checks. No vibes.
if (!parsed.userId) return null;
if (!parsed.email) return null;
if (!Array.isArray(parsed.roles)) return null;
if (typeof parsed.expiresAt !== "number") return null;
if (Date.now() > parsed.expiresAt) return null;
// Optional: bind to user-agent to reduce replay.
const ua = headers().get("user-agent") || "";
if (ua.length === 0) return null;
return parsed;
} catch {
return null;
}
}
This snippet isn’t “secure auth”. It’s structure.
The point: every request goes through the same gates. If I change cookie format, I change one file.
One thing that bit me — Cursor happily generated JSON.parse(cookies().get(...)) with no try/catch. That crashed rendering for anyone with a stale cookie.
2) I make middleware do one job: block obvious bad routes
I used to do too much in middleware.
Then I got stuck in redirect loops. Brutal.
Now middleware only answers: “Can this request touch /app?” It doesn’t fetch user profiles. It doesn’t refresh tokens. It doesn’t call Supabase.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const { pathname, searchParams } = req.nextUrl;
// Only protect app routes.
if (!pathname.startsWith("/app")) return NextResponse.next();
const hasSessionCookie = req.cookies.has("session");
if (hasSessionCookie) return NextResponse.next();
const loginUrl = req.nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.searchParams.set("next", pathname + (searchParams.size ? `?${searchParams}` : ""));
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/app/:path*"],
};
That’s it.
Why so dumb-simple? Because I can reason about it when I’m tired.
Also: middleware runs on Edge by default. Half the Node APIs aren’t there. Cursor will still generate code that uses crypto in middleware and you’ll get a fun error like:
Module not found: Can't resolve 'crypto'
So I keep middleware boring. Server-only logic stays server-only.
3) I fuzz my auth endpoints with a 30-line script
This is the part that changed everything.
I don’t wait for “a user reported it.” I try to break my own app.
I keep a script in scripts/fuzz-auth.mjs. It hits a few routes with:
- no cookie
- garbage cookie
- expired cookie
- huge cookie
It catches crashes. It catches accidental 200s. It catches HTML pages returning JSON errors.
// scripts/fuzz-auth.mjs
const base = process.env.BASE_URL || "http://localhost:3000";
const cases = [
{ name: "no cookie", cookie: null },
{ name: "garbage", cookie: "session=lol-nope" },
{ name: "expired-ish", cookie: "session=ZXhwaXJlZA" },
{ name: "huge", cookie: "session=" + "a".repeat(9000) },
];
const paths = ["/app", "/app/settings", "/api/me"]; // adjust for your app
for (const p of paths) {
for (const c of cases) {
const res = await fetch(base + p, {
redirect: "manual",
headers: c.cookie ? { cookie: c.cookie } : {},
});
const ct = res.headers.get("content-type") || "";
console.log(`${p} | ${c.name} | ${res.status} | ${ct.split(";")[0]}`);
// If your API routes should never return HTML, enforce it.
if (p.startsWith("/api") && ct.includes("text/html")) {
process.exitCode = 1;
}
// Catch server crashes disguised as 200.
if (res.status >= 500) {
process.exitCode = 1;
}
}
}
Run it like:
BASE_URL=http://localhost:3000 node scripts/fuzz-auth.mjs
This is where Claude helps a lot.
I paste the console output. I paste the route code. I ask: “Why is /api/me returning HTML for garbage cookies?” Then I fix the actual bug.
But I don’t let it rewrite everything. I tell it: keep the shape, change the smallest surface area.
4) I add 3 tests. Only 3. They’re mean.
I don’t write 40 auth tests. I won’t maintain them.
I write three that stop the bleeding:
1) protected page redirects when no cookie
2) API returns 401 JSON when no cookie
3) API returns 401 JSON when cookie is invalid
In App Router, I like testing the session parser directly. It’s stable and fast.
Here’s a Vitest unit test for the token decoding logic. I keep it pure by extracting decode into a helper.
// lib/session-decode.ts
export type Session = {
userId: string;
email: string;
roles: string[];
expiresAt: number;
};
function base64UrlToString(input: string) {
const pad = "=".repeat((4 - (input.length % 4)) % 4);
const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}
export function decodeSessionCookie(token: string): Session | null {
try {
const json = base64UrlToString(token);
const parsed = JSON.parse(json) as Session;
if (!parsed.userId) return null;
if (!parsed.email) return null;
if (!Array.isArray(parsed.roles)) return null;
if (typeof parsed.expiresAt !== "number") return null;
if (Date.now() > parsed.expiresAt) return null;
return parsed;
} catch {
return null;
}
}
// test/session-decode.test.ts
import { describe, it, expect } from "vitest";
import { decodeSessionCookie } from "../lib/session-decode";
function toBase64Url(s: string) {
return Buffer.from(s, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
describe("decodeSessionCookie", () => {
it("returns null for garbage", () => {
expect(decodeSessionCookie("lol-nope")).toBeNull();
});
it("returns null for expired", () => {
const token = toBase64Url(
JSON.stringify({
userId: "u1",
email: "a@b.com",
roles: ["user"],
expiresAt: Date.now() - 1,
})
);
expect(decodeSessionCookie(token)).toBeNull();
});
it("parses a valid session", () => {
const token = toBase64Url(
JSON.stringify({
userId: "u1",
email: "a@b.com",
roles: ["user"],
expiresAt: Date.now() + 60_000,
})
);
const s = decodeSessionCookie(token);
expect(s?.userId).toBe("u1");
expect(s?.roles).toEqual(["user"]);
});
});
Yeah, it’s “just parsing”.
But it stops regressions when I change cookie structure. And Claude is good at suggesting extra assertions without turning the test into a novel.
Results
On my last build week, I shipped auth changes on 6 different days. Real life.
Before this workflow, I’d usually find 2 or 3 auth regressions per week. Stuff like: /api/me returning a 500 for a bad cookie, or a protected page rendering and then redirecting client-side.
After centralizing session reads + adding the fuzz script + the 3 tests, I hit 0 auth-related 500s locally across 27 fuzz runs (3 paths × 4 cases × reruns while fixing). And I stopped seeing redirect loops entirely because middleware stopped doing “smart” things.
Not magic. Just fewer moving parts.
Key takeaways
- Put all cookie/session reads in one server-only module.
- Keep middleware dumb: allow/deny + redirect only.
- Fuzz auth with a script. It’s faster than clicking.
- Add 3 unit tests that you’ll actually maintain.
- When Claude writes auth code, assume it forgot the unhappy path.
Closing
If you already use Cursor + Claude for auth, don’t ask it for “secure auth”. Ask it for smaller, testable pieces.
What’s the one fuzz case you run against your auth routes that caught a real bug (cookie size, invalid JWT, missing headers, double-submit, something else)?
Top comments (0)