DEV Community

Sathish
Sathish

Posted on

Cursor + Claude: stop shipping broken auth flows

  • 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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*"],
};
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it like:

BASE_URL=http://localhost:3000 node scripts/fuzz-auth.mjs
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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"]);
  });
});
Enter fullscreen mode Exit fullscreen mode

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)