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_SECRETAUTH_SECRETJWT_SECRETSESSION_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 32Paste 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.tsFor 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'(orstrict) — 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
Most auth tutorials show you how to add a login button. This is the guide that shows you how to add auth that actually works — what to wire up, what AI tools get wrong, and the bugs you ship if you copy-paste the first Stack Overflow answer.
It's 3 AM. You scrolled X and saw a tweet about a Lovable / Bolt / v0 app leaking customer data. You start wondering if yours is next. Here is the exact checklist to run in the next 30 minutes — what to check, what to fix first, and how to stop having this problem.
A prospect just emailed asking 'is your app secure?' You don't have a real answer. Here is the honest playbook — what to say, what evidence to point at, and how to turn this question from a deal-stopper into a deal-accelerator. Written for solo founders who don't want to lie.
Every AI-generated Next.js app ships with middleware.ts that looks like it gates admin routes. Half of them do not actually run on the routes they think they run on. Here is the 5-minute test, the canonical bugs, and the fixes — written for solo founders who do not want to read the matcher RFC.