9 min read

How to audit your Cursor-generated auth code (a 30-minute checklist)

Cursor wrote your authentication code. It compiled, it works, you shipped it. But you haven't actually checked whether it's secure. Here is the 30-minute audit checklist — six bugs to look for, three commands to run, and the one tool that does this on every PR forever.

You asked Cursor to "set up authentication for my app." Cursor produced 600 lines across lib/auth.ts, middleware.ts, app/login/page.tsx, and a Supabase config. It compiled. It works — you can sign in, sign out, see your dashboard.

You have not actually checked whether it's secure.

This is the 30-minute audit. Six things to look for, three commands to run, one decision at the end.

Bug 1 — Session secret is the default or hardcoded

Open .env and .env.local. Look for these:

  • NEXTAUTH_SECRET
  • AUTH_SECRET
  • JWT_SECRET
  • SESSION_SECRET

Each should be a long random string. If any is missing, set to "secret", set to "your-secret-here", or set to the value Cursor's example used (Cursor sometimes uses literal placeholder strings if you didn't fill in the env vars), your sessions are forgeable. Anyone who knows your secret can mint valid session tokens for any user.

Fix: generate a real secret + update the env var in Vercel:

openssl rand -base64 32

Paste the output as the new value. Redeploy. Existing sessions get invalidated, which is the right behavior.

Bug 2 — Auth callback validates nothing

OAuth flows have a callback URL where the provider posts back tokens. Common Cursor-generated pattern:

// app/auth/callback/route.ts
export async function GET(req) {
  const code = req.nextUrl.searchParams.get('code');
  const tokens = await exchangeCode(code);
  // Sets the session cookie. No state validation.
  setSessionCookie(tokens);
  redirect('/');
}

Two things missing:

1. State parameter check. Without state validation, you have CSRF on your OAuth flow — an attacker can trick a user into completing OAuth with the attacker's account, then the attacker logs in as the user (account linking attack). 2. Audience claim verification on the returned token. Without it, a token issued for a different application can be replayed against yours.

Fix: generate a state parameter on auth start, store it in a cookie, validate on callback:

``ts // app/auth/login/route.ts const state = crypto.randomUUID(); cookies().set('oauth_state', state, { httpOnly: true, sameSite: 'lax', secure: true, maxAge: 600 }); redirect(https://provider.com/oauth?state=${state}&...`);

// app/auth/callback/route.ts const state = req.nextUrl.searchParams.get('state'); const expected = cookies().get('oauth_state')?.value; if (!state || state !== expected) { return new Response('Invalid state', { status: 400 }); } cookies().delete('oauth_state'); const tokens = await exchangeCode(code); const claims = await verifyJwt(tokens.id_token, { audience: process.env.OAUTH_CLIENT_ID }); ```

If you're using NextAuth or Clerk, this is handled for you — verify your config has audience set correctly. If you're rolling your own, write the state + audience check.

Bug 3 — Cookie is not httpOnly / not secure

In your auth code, find where you set the session cookie:

grep -rn 'cookies().set\|res.cookie\|setCookie' app/ lib/ middleware.ts

For each match, the options object MUST have:

  • httpOnly: true — prevents JavaScript from reading the cookie (defends against XSS)
  • secure: true — only sent over HTTPS (or in dev, scoped to localhost)
  • sameSite: 'lax' (or strict) — defends against CSRF

If any of these are missing, fix them. Cursor sometimes generates cookie code without these for "compatibility" — the compatibility loss is browsers that don't matter; the security loss is real.

cookies().set('session', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 7,
  path: '/',
});

Bug 4 — Password reset token has no expiry

If your app has password reset, find the reset-token generation code. Common pattern:

const token = crypto.randomBytes(32).toString('hex');
await db.from('password_resets').insert({ user_id, token });
await sendEmail(user.email, `Reset: /reset?token=${token}`);

Bug: tokens never expire. A reset link from 2 years ago still works. Worse: anyone who scrapes an old email or finds an old log entry can use it.

Fix: expiry + single-use:

```ts const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await db.from('password_resets').insert({ user_id, token, expires_at: expiresAt, used: false });

// On reset: const reset = await db.from('password_resets').select().eq('token', token).single(); if (!reset.data || reset.data.used || reset.data.expires_at < new Date()) { return error('Invalid or expired token'); } await db.from('password_resets').update({ used: true }).eq('id', reset.data.id); // ...do the reset ```

Compare reset tokens with constant-time comparison too — crypto.timingSafeEqual instead of === — to prevent timing attacks.

Bug 5 — User-enumeration via signup or login error messages

Try signing up with an email that already exists. Read the error.

If the response is "this email is already registered," congratulations: you have user enumeration. An attacker can scrape the email list of every user by trying signups and reading the error messages.

Fix: return generic errors:

```ts // On signup: return Response.json({ ok: true }); // Always success-shaped; send a "verify your email" email regardless

// On login error: return Response.json({ error: 'Invalid email or password' }); // Same error for "user not found" + "wrong password"

// On password reset: return Response.json({ ok: true }); // Always success-shaped; send a "if you have an account" email ```

The UX cost is low (users sometimes get a verify-email when they shouldn't); the security gain is real (no enumeration).

Bug 6 — No rate limiting on auth endpoints

Try logging in with the wrong password 100 times in a row. If it works (no rate limit), an attacker can brute-force your users' passwords.

Fix: rate limit by IP + email at the auth endpoints:

```ts import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per IP per minute });

export async function POST(req) { const ip = req.headers.get('x-forwarded-for') ?? 'unknown'; const { success } = await ratelimit.limit(ip); if (!success) return new Response('Too many attempts', { status: 429 }); // ... actual login logic } ```

Vercel and Upstash both offer cheap rate-limiters that drop in. Cloudflare's free tier also handles it.

The three commands

Run these three:

```bash # 1. Find every cookie-setting site: grep -rn 'cookies().set\|res.cookie' app/ lib/ middleware.ts

# 2. Find every place a session is read: grep -rn 'cookies().get\|req.cookies\|jwt.verify' app/ lib/ middleware.ts

# 3. Find every place that does an authz check on user role: grep -rn 'role\|admin\|isAdmin\|claims' app/ lib/ middleware.ts | head -30 ```

For each match in (1) and (2), verify httpOnly + secure + sameSite. For each match in (3), verify the check is what you think it is.

The decision

After 30 minutes of audit, you have one of two outcomes:

1. No bugs found. Great. But "no bugs found in a 30-minute audit" is not "no bugs." A solo founder cannot audit every PR forever.

2. Bugs found. Fix them now. Then automate the audit.

Either way, the long-term solution is the same:

Securie runs the broken-auth specialist (Day-1, production-validated) on every PR. It detects all six of the bugs above plus a long tail of related ones (BOLA, IDOR, BFLA, missing auth boundary, OAuth state-parameter omission, JWT audience-claim missing, etc.). Findings ship only when the sandbox successfully reproduces the attack — no noise, no triage.

Two minutes to install. Zero engineering work to maintain.

Related

Related posts