Part 1 covered the mistakes everyone knows they should fix but often don't: XSS, SQL injection, weak authentication, and data exposure. Part 2 went deeper into the overlooked ones — CSRF, rate limiting, dependency vulnerabilities, file upload flaws, and secrets management.
Part 3 is about the invisible mistakes. The ones that don't show up in a code review because the feature works fine. No error, no warning, no test failure. The attack surface is just quietly open — until it isn't.
1. CORS Misconfiguration
What it is: CORS (Cross-Origin Resource Sharing) controls which websites can make requests to your API. A misconfigured CORS policy can let any website read responses from your authenticated endpoints — including data that belongs to your users.
Vulnerable code
// BAD — allows any origin to access your API, including with credentials
app.use(cors({ origin: '*' }))
// BAD — reflects whatever origin sent the request back without validation
// Any site that sends a request gets treated as allowed
app.use(cors({ origin: true }))
The origin: true variant is especially dangerous. It looks like a configuration option, but it tells Express to echo the requester's Origin header directly back as the allowed origin. An attacker's site sends Origin: https://evil.com, your server responds with Access-Control-Allow-Origin: https://evil.com, and the browser grants full access.
origin: '*' is safe for fully public, unauthenticated APIs — but the moment your endpoint uses cookies or Authorization headers, the wildcard stops working as a protection. Browsers refuse to send credentials to a wildcard origin by default, but developers often respond by also setting credentials: true, which is where it breaks down.
Fixed code
// GOOD — explicit allowlist, validates before granting access
const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com']
app.use(
cors({
origin: (origin, callback) => {
// Allow server-to-server requests (no Origin header)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
credentials: true, // safe here — only allowlisted origins get through
}),
)
For Next.js API routes, set headers directly:
// GOOD — Next.js API route with CORS control
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? ''
const allowed = allowedOrigins.includes(origin) ? origin : ''
return new Response(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
Vary: 'Origin', // critical: tells CDNs the response varies by origin
},
})
}
The Vary: Origin header is easy to miss. Without it, a CDN might cache the response for https://myapp.com and serve it to requests from https://evil.com — bypassing your CORS validation entirely.
Key takeaway: Never use
origin: '*'on authenticated endpoints. Never useorigin: true. Maintain an explicit allowlist. SetVary: Originwhen caching is involved.
2. Missing HTTP Security Headers
What it is: Browsers ship with built-in security mechanisms — XSS filters, MIME type enforcement, framing controls, HTTPS upgrades. But they're opt-in. Your server has to send the right headers to activate them. Most apps don't.
Vulnerable code
// BAD — default Express app, no security headers
const app = express()
app.use(express.json())
// No helmet, no manual headers
// The browser gets zero guidance on security policy
A response without security headers leaves the browser in permissive mode. Your content can be framed by other sites, MIME types can be sniffed, HTTP connections aren't upgraded, and there's no policy controlling what scripts can load.
Fixed code
// GOOD — Helmet.js applies a secure baseline in one line
import helmet from 'helmet'
app.use(helmet())
What helmet() actually sets (and why each one matters):
| Header | What it does |
|---|---|
Strict-Transport-Security |
Forces HTTPS for future visits (HSTS). Prevents SSL stripping attacks. |
X-Content-Type-Options: nosniff |
Stops browsers from guessing the MIME type of a response. Prevents a .txt file from being executed as JavaScript. |
X-Frame-Options: SAMEORIGIN |
Prevents your site from being embedded in iframes on other domains. Clickjacking defense — more on this below. |
X-XSS-Protection: 0 |
Disables the legacy XSS auditor (which was itself vulnerable). Modern browsers use CSP instead. |
Referrer-Policy: no-referrer |
Controls how much of your URL gets sent as the Referer header to other sites. Prevents leaking path or query params containing session IDs or sensitive data. |
Content-Security-Policy |
Restricts which scripts, styles, and resources the page can load. The most powerful XSS mitigation available to you. |
Helmet's defaults are conservative. You'll likely need to tune the Content Security Policy for your app:
// GOOD — customized Helmet for an app that loads external fonts and analytics
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://www.googletagmanager.com'],
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
},
},
}),
)
For Next.js, add headers in next.config.ts since you can't use Express middleware:
// GOOD — Next.js security headers via next.config.ts
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
]
}
You can verify your headers at securityheaders.com — paste your URL and it scores your configuration with explanations for each missing header.
Key takeaway: Use Helmet.js in Express. In Next.js, configure headers in
next.config.ts. Check your score at securityheaders.com. The CSP is the hardest to configure but worth it.
3. Open Redirects
What it is: Your app accepts a URL in a query parameter and redirects the user to it after an action — typically login or logout. Without validation, an attacker crafts a link to your trusted domain that silently forwards the user to a phishing page.
The attack looks like this: https://myapp.com/login?next=https://evil.com/fake-login
The user sees myapp.com in the address bar before clicking. The email link looks legitimate. After logging in, they land on a pixel-perfect clone of your app asking them to "verify their session."
Vulnerable code
// BAD — redirects to any URL without validation
app.get('/login', async (req, res) => {
const { email, password } = req.body
const user = await authenticate(email, password)
if (!user) return res.status(401).send('Invalid credentials')
const next = req.query.next as string
res.redirect(next || '/')
// Attacker sends: /login?next=https://evil.com
// User logs in successfully, lands on evil.com
})
Fixed code
// GOOD — validate the redirect target is an internal path
function isSafeRedirect(url: string): boolean {
if (!url) return false
try {
// Resolve relative to our own origin to detect absolute URLs
const parsed = new URL(url, 'https://myapp.com')
return parsed.origin === 'https://myapp.com'
} catch {
return false // malformed URL — reject
}
}
app.get('/login', async (req, res) => {
const { email, password } = req.body
const user = await authenticate(email, password)
if (!user) return res.status(401).send('Invalid credentials')
const next = req.query.next as string
res.redirect(isSafeRedirect(next) ? next : '/')
})
The new URL(url, base) trick is the cleanest way to catch the common bypass attempts:
https://evil.com— absolute URL, different origin, blocked//evil.com/path— protocol-relative URL, resolved ashttps://evil.com, blocked/internal/path— relative URL, resolves tohttps://myapp.com/internal/path, allowedhttps://myapp.com.evil.com— different origin (.com.evil.com), blocked
Don't try to build this with regex. There are too many encoding and protocol tricks that defeat pattern matching. Parse the URL and compare origins.
Key takeaway: Never redirect to user-supplied URLs without validation. Use
new URL()to parse and compare origins. Fallback to/for anything that fails validation.
4. Clickjacking
What it is: An attacker embeds your app in a transparent iframe overlaid on their malicious page. The user thinks they're interacting with the attacker's site — clicking a "claim your prize" button — but the clicks are landing on your app underneath. One click can confirm a purchase, delete an account, or change account settings.
Vulnerable code
// BAD — no frame protection
// Your app can be embedded in an iframe on any domain
// The attacker's page sits on top, invisible to the user
app.get('/', (req, res) => {
res.send(html) // no framing controls set
})
Fixed code
// GOOD — X-Frame-Options header (broad browser support)
res.setHeader('X-Frame-Options', 'DENY')
// DENY = cannot be framed anywhere
// SAMEORIGIN = can only be framed by the same origin
// GOOD — CSP frame-ancestors (modern, more flexible, overrides X-Frame-Options)
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
// 'none' = no site can frame this page
// 'self' = only your own origin can frame it
// 'https://partner.com' = only this specific domain can embed it
// Both can coexist — X-Frame-Options for legacy browsers, CSP for modern
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
If you're using Helmet.js (as covered above), clickjacking protection is already included — Helmet sets X-Frame-Options: SAMEORIGIN and lets you configure frameAncestors in the CSP.
One nuance: if your app legitimately needs to be embeddable (say, a widget meant for third-party sites), use frame-ancestors 'https://trusted-partner.com' instead of 'none'. Don't disable framing protection just because you haven't thought about it.
Key takeaway: Set
X-Frame-Options: DENYandContent-Security-Policy: frame-ancestors 'none'unless you have a specific reason to allow framing. Helmet handles this automatically.
5. Insecure API Design
What it is: APIs that expose too much data, accept too many input fields, or trust the caller too much. These aren't single-line vulnerabilities — they're design decisions that compound over time.
Mass Assignment
// BAD — updates whatever the client sends
app.patch('/api/users/:id', async (req, res) => {
const { id } = req.params
await db.user.update({
where: { id },
data: req.body, // attacker sends: { "role": "admin", "isVerified": true, "planId": "enterprise" }
})
res.json({ success: true })
})
This one is subtle because it looks like clean, general code. The endpoint works perfectly for legitimate requests. The problem only appears when you realize that req.body might contain fields the user shouldn't be able to set.
// GOOD — explicit allowlist of writable fields
app.patch('/api/users/:id', async (req, res) => {
const { id } = req.params
const { name, email, bio } = req.body // only fields users can update
await db.user.update({
where: { id },
data: { name, email, bio },
})
res.json({ success: true })
})
For anything more than a few fields, use a validation library like Zod to define the schema explicitly:
// GOOD — Zod schema enforces both structure and allowed fields
import { z } from 'zod'
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
bio: z.string().max(500).optional(),
})
app.patch('/api/users/:id', async (req, res) => {
const result = UpdateUserSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() })
}
await db.user.update({ where: { id: req.params.id }, data: result.data })
res.json({ success: true })
})
Broken Object-Level Authorization (IDOR)
// BAD — trusts the URL parameter without checking ownership
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
if (!invoice) return res.status(404).json({ error: 'Not found' })
res.json(invoice)
// Attacker changes /api/invoices/their-id to /api/invoices/victim-id
// Gets someone else's invoice — no ownership check
})
This is the #1 API vulnerability on the OWASP API Top 10. The endpoint works correctly for the intended user, so it passes every functional test. The flaw is that any authenticated user can access any other user's data just by changing the ID in the URL.
// GOOD — verify the resource belongs to the requesting user
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({
where: { id: req.params.id, userId: req.user.id }, // ownership in the query
})
if (!invoice) return res.status(404).json({ error: 'Not found' })
res.json(invoice)
})
Returning 404 instead of 403 for unauthorized access is intentional — it prevents attackers from enumerating which IDs exist. If they get 403, they know the resource exists and can try other approaches. A 404 reveals nothing.
Apply this pattern to every endpoint that fetches, updates, or deletes a resource by ID. If the resource has an owner, the query must include the ownership check.
Key takeaway: Destructure
req.bodyto specific fields, or validate through a schema. Never passreq.bodydirectly into a database update. Always verify resource ownership — don't trust that the authenticated user owns the resource they're requesting.
Part 3 Checklist
Before shipping features that involve cross-origin requests, headers, redirects, or API input handling:
- CORS origin is an explicit allowlist — never
*on authenticated endpoints, neverorigin: true -
Vary: Originset when responses with CORS headers are cached - Helmet.js installed in Express, or security headers configured in
next.config.ts - CSP configured — even a basic policy is better than none
- Redirect targets validated with
new URL()— unsafe URLs fall back to/ -
X-Frame-Options: DENYorframe-ancestors 'none'set - API update endpoints destructure or validate fields — no
data: req.body - Every resource endpoint verifies ownership — never trust IDs from the URL alone
Three parts in, and we've only scratched the surface. The deeper you look, the more you realize — security isn't a checklist you finish. It's a muscle you build.
This post was written with the assistance of AI to help articulate the author's own views, knowledge, and experiences.