Tools · · 8 min read

Is Your Cursor-Built App Secure? 7 Vulnerabilities to Check

Common security issues in Cursor-generated code and how to fix them before launch.

Cursor is one of the most capable AI coding tools on the market. It writes clean, functional code at speed, and for many solo founders it has become the primary way they build their products. But there is a consistent pattern we see across the Cursor-built applications that come through our audit process: the code works, but it is not secure.

This is not a Cursor-specific failing. It is a property of how large language models generate code. They optimise for functionality, not defence. The result is applications that demo well but ship with vulnerabilities that any competent attacker could exploit.

We have audited dozens of Cursor-built apps at this point. These are the seven vulnerabilities we find most often, along with concrete fixes you can apply today.

1. Hardcoded API Keys and Secrets

This is the single most common issue we encounter. Cursor will happily embed API keys, database connection strings, and third-party service credentials directly into your source code. It does this because it is trying to make the code work immediately, and hardcoding a value is the fastest path to functional code.

The problem is obvious: anyone who gains access to your repository, your build output, or your client-side JavaScript bundle can extract those keys and use them.

Before
// Cursor often generates this
const stripe = new Stripe(
'sk_live_51ABC123...',
{ apiVersion: '2024-12-18' }
);

const supabase = createClient(
'https://xyz.supabase.co',
'eyJhbGciOiJIUzI1NiIs...'
);
After
// Move secrets to environment variables
const stripe = new Stripe(
process.env.STRIPE_SECRET_KEY!,
{ apiVersion: '2024-12-18' }
);

const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

If you have ever committed a secret to version control, rotating it is not optional. Even if you have since removed it, the value is still accessible in your Git history. Rotate the key, then use git filter-branch or BFG Repo-Cleaner to scrub the history.

The fix: Use environment variables for all secrets. Add a .env.example file with placeholder values so Cursor understands the pattern. Add .env* to your .gitignore before your first commit. Use a secrets manager like Doppler or Infisical for production deployments.

2. Missing Input Validation on API Routes

Cursor generates API routes that trust incoming data implicitly. It will destructure request bodies and pass values straight to database queries or business logic without any validation. This is an open invitation for injection attacks, type confusion bugs, and application crashes.

Before
// No validation at all
export async function POST(req: Request) {
const { email, role, teamId } = await req.json();

const user = await db.user.create({
  data: { email, role, teamId },
});

return Response.json(user);
}
After
// Validate everything at the boundary
import { z } from 'zod';

const CreateUserSchema = z.object({
email: z.string().email().max(255),
role: z.enum(['member', 'admin']),
teamId: z.string().uuid(),
});

export async function POST(req: Request) {
const body = await req.json();
const result = CreateUserSchema.safeParse(body);

if (!result.success) {
  return Response.json(
    { error: 'Invalid input' },
    { status: 400 }
  );
}

const user = await db.user.create({
  data: result.data,
});

return Response.json(user);
}

The fix: Use Zod (or a similar validation library) at every API boundary. Define schemas for all incoming data. Reject anything that does not match. This is not optional — it is your first line of defence against a wide range of attacks.

3. Insecure JWT Configuration

When Cursor sets up authentication with JWTs, it tends to use weak or default signing secrets, skip expiration times, and neglect audience or issuer validation. We have seen apps in production with JWTs signed using the string "secret".

Before
// Weak JWT setup
const token = jwt.sign(
{ userId: user.id, role: user.role },
'secret'
);

// Verification without options
const decoded = jwt.verify(token, 'secret');
After
// Proper JWT configuration
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET!, // 256-bit random key
{
  expiresIn: '15m',
  issuer: 'your-app.com',
  audience: 'your-app.com',
}
);

const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'your-app.com',
audience: 'your-app.com',
maxAge: '15m',
});

The fix: Generate a cryptographically random secret of at least 256 bits. Set short expiration times (15 minutes for access tokens). Always validate issuer and audience claims. Use refresh tokens for longer sessions.

4. SQL Injection via String Concatenation

Even in 2026, we still see SQL injection. Cursor sometimes builds queries using template literals or string concatenation instead of parameterised queries, particularly when the prompt describes a complex filter or search feature.

Before
// String concatenation = SQL injection
const users = await db.query(
`SELECT * FROM users
 WHERE name LIKE '%${searchTerm}%'
 AND team_id = '${teamId}'`
);
After
// Parameterised queries are safe
const users = await db.query(
'SELECT * FROM users WHERE name LIKE $1 AND team_id = $2',
[`%${searchTerm}%`, teamId]
);

// Or use an ORM with built-in protection
const users = await prisma.user.findMany({
where: {
  name: { contains: searchTerm },
  teamId: teamId,
},
});

SQL injection is not a theoretical risk. Automated scanners actively probe every publicly accessible endpoint for injection vulnerabilities. If your app has one, it will be found.

The fix: Use parameterised queries or a well-maintained ORM. Never concatenate user input into SQL strings. If you are using Supabase, use the JavaScript client library rather than raw SQL — it handles parameterisation for you.

5. Missing CORS Configuration

Cursor-generated API routes almost never include CORS headers. In development this is not noticeable because same-origin requests work fine. In production, the absence of an explicit CORS policy means browsers will apply default restrictions, which can either block legitimate requests or, if misconfigured, allow any origin to call your API.

Before
// No CORS headers at all
export async function GET(req: Request) {
const data = await fetchData();
return Response.json(data);
}

// Or dangerously permissive
res.setHeader(
'Access-Control-Allow-Origin', '*'
);
After
// Explicit, restrictive CORS
const ALLOWED_ORIGINS = [
'https://yourapp.com',
'https://www.yourapp.com',
];

export async function GET(req: Request) {
const origin = req.headers.get('origin');

const headers = new Headers();
if (origin && ALLOWED_ORIGINS.includes(origin)) {
  headers.set('Access-Control-Allow-Origin', origin);
  headers.set('Access-Control-Allow-Methods', 'GET');
  headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  headers.set('Access-Control-Max-Age', '86400');
}

const data = await fetchData();
return Response.json(data, { headers });
}

The fix: Define an explicit allowlist of permitted origins. Never use * for APIs that require authentication. Handle preflight OPTIONS requests properly. Use a middleware or framework-level CORS configuration rather than setting headers manually in every route.

6. Exposed Error Details in Production

Cursor generates helpful error handling for development: full stack traces, database error messages, internal state dumps. The problem is that it does not differentiate between development and production environments. Those detailed error messages go straight to the client in production, giving attackers a roadmap of your internal architecture.

Before
// Leaks internal details
try {
const result = await db.query(sql);
return Response.json(result);
} catch (error) {
return Response.json(
  { error: error.message, stack: error.stack },
  { status: 500 }
);
}
After
// Safe error handling
try {
const result = await db.query(sql);
return Response.json(result);
} catch (error) {
// Log full details server-side
console.error('DB query failed:', {
  error: error.message,
  stack: error.stack,
  query: sql,
});

// Return generic message to client
return Response.json(
  { error: 'An internal error occurred' },
  { status: 500 }
);
}

The fix: Log detailed errors server-side using a proper logging service (Sentry, LogSnag, Axiom). Return generic error messages to the client. Never expose stack traces, database error messages, or internal paths in API responses.

7. Missing Rate Limiting

Of all the vulnerabilities on this list, missing rate limiting is the one Cursor is least likely to add on its own. We have never seen Cursor proactively add rate limiting to an API route. This leaves your application wide open to brute-force attacks, credential stuffing, and denial-of-service.

Before
// No rate limiting
export async function POST(req: Request) {
const { email, password } = await req.json();

const user = await authenticate(email, password);

if (!user) {
  return Response.json(
    { error: 'Invalid credentials' },
    { status: 401 }
  );
}

return Response.json({ token: createToken(user) });
}
After
// With rate limiting via Upstash
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15m'),
prefix: 'auth:login',
});

export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success, remaining } = await ratelimit.limit(ip);

if (!success) {
  return Response.json(
    { error: 'Too many attempts. Try again later.' },
    { status: 429 }
  );
}

const { email, password } = await req.json();
const user = await authenticate(email, password);

if (!user) {
  return Response.json(
    { error: 'Invalid credentials' },
    { status: 401 }
  );
}

return Response.json({ token: createToken(user) });
}

At a minimum, rate limit your authentication endpoints, password reset flows, and any endpoint that sends emails or SMS. These are the most commonly abused routes.

The fix: Use Upstash, Redis, or a similar service to implement rate limiting on sensitive endpoints. Apply stricter limits to authentication routes (5 attempts per 15 minutes is a reasonable starting point). Return 429 Too Many Requests with a Retry-After header.

What to Do Next

If you are building with Cursor, you are not doing anything wrong. It is an excellent tool for moving fast. But moving fast without a security review is how applications get breached.

Run through these seven checks on your own codebase. If you find issues — and you almost certainly will — fix them before you launch. If you want an expert to do a thorough review, that is exactly what our Vibe Code Audit service provides.

You might also want to work through our complete Vibe Coding Security Checklist for a more comprehensive pre-launch review.

Ready to ship with confidence?

Get your AI-generated app audited by UK security experts.

See Pricing

Or email us at hello@vibecodeaudits.co.uk

Related Articles