AWS Builder Center

Six Security Mistakes I Shipped to Production

The six security bugs I shipped to real client sites, what each one actually cost, and the prevention rule that stops it happening twice.

I'm a second-year CS student in Algeria. I build client sites for a living -> a nutrition clinic, a diet and training centre with an admin dashboard and online registration, a phone-accessory shop. Real clients. Real production. Real inboxes I get yelled at.
The stack: Next.js 14 App Router, Tailwind v4, Prisma on Supabase Postgres, bcrypt plus JWT via NextAuth, Resend for the email, Railway and Vercel for hosting, Cloudflare Tunnel in front of it, UFW, fail2ban and Caddy underneath.
The strongest thing I've ever built is a ~1,000-line reusable security skill covering the OWASP Top 10, which I now paste into every project before I write a single route. I'm not going to pretend it came out clean. It came out of six incidents, five of which happened to a live client.
Vibe coding is fine. Vibe shipping is not.
Code that feels right in the editor takes ten minutes. Code that leaves my machine and hits someone else's server is a bet. I made that bet with a security header, a rate limiter, a validator and a redirect. I lost all four. Here's the bill.
Every mistake below follows the same shape -> the symptom, the root cause, the real fix, and the rule I wrote in the skill afterwards.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
internet
|
v
+---------------------+
| Cloudflare Tunnel |
+---------------------+
|
v
+---------------------+
| Caddy (TLS) |
+---------------------+
|
v
+---------------------+
| UFW + fail2ban | <- drops known-bad IPs
+---------------------+
|
v
+---------------------+
| Vercel edge / |
| Railway ingress |
+---------------------+
|
v
+----------------------------------------+
| middleware.ts |
| |
| 1. matcher -> which routes apply |
| 2. headers -> CSP, HSTS, nosniff |
| 3. nonce -> fresh, per request |
| 4. rateLimit -> KEYED PER ENDPOINT |
| 5. session -> JWT check |
+----------------------------------------+
| |
allow | | deny / redirect
v v
+---------------+ +------------------+
| route.ts | | 307 -> /login |
| Zod validate | | headers RE-SET |
+---------------+ +------------------+
| |
v v
200 / 400 / 429 307 + Retry-After
That diagram is the only mental model that matters. Every one of these bugs lived somewhere on it. Not in the code I thought I was writing.

Mistake 1: I shipped no-sniff, which is not a security header

Symptom. I set X-Content-Type-Options: no-sniff with a hyphen. It was in production for weeks. Every dashboard route.
Root cause. The header value is a single exact token: nosniff. No space. No hyphen. No variants. Browsers ignore the entire header when the value doesn't match exactly, and they do it silently. No warning, no console error, nothing in any security scanner. I had a green checkmark on a header that did nothing.
The real fix. One character. But "one character" is the whole lesson -> this isn't a bug you find by reasoning, you find it by reading the response header in DevTools against the spec. Compare your value to the spec character by character. Not "does it look about right".
Prevention. Centralise every header in one function, never inline them per route, and curl your own production URL and diff the output against the spec. Five minutes. It found three more mistakes in the same afternoon.

Mistake 2: one shared rate-limit Map took down every endpoint at once

Symptom. The training centre's registration form and its admin dashboard are different routes. A burst on the form killed the dashboard. Same for the phone shop's contact form. Everything degraded together.
Root cause. My first rate limiter was one shared in-memory Map for all routes. One map meant one bucket per IP across the entire app. It was, in my own words at the time, bypassable by simply switching endpoints.
The second version fixed the keying — contact: plus the IP instead of just the IP — but I swept expired keys lazily, from inside the request handler. A burst created a lot of keys, a quiet night left them all sitting there, and the map never fully emptied. Slow leak. Both versions are in my bug journal, side by side, like a before-and-after photo of a bad decision.
Prevention. Separate bucket per endpoint, one global sweep on a timer instead of per-request cleanup, and a hard cap on key count so memory is bounded even if the sweep is wrong. An in-memory limiter is per-instance anyway -> on Vercel it's not even a global, so treat it as a courtesy, not a wall.

Mistake 3: I returned 429 with no Retry-After header

Symptom. My own frontend hammered the endpoint. 429 came back, the client retried immediately, 429 came back again. A small loop that turned a rate limit into a self-inflicted load generator.
Root cause. A 429 without Retry-After is an unfinished conversation. The status code says "stop". The header is what says "and here's when". Without it, every well-behaved client has to guess, and the correct guess for a polite library is "immediately, once".
Prevention. Always compute the seconds until the window resets and send it. Round up, never down. And return the standard X-RateLimit-Limit, -Remaining and -Reset alongside it so a client can budget instead of guess.

Mistake 4: my validator checked three fields and forgot the fourth

Symptom. A week of incomplete contact requests. No exception. No console error. No failed test. The form rendered, the request returned 200, the email said "new message" — with the body empty.
Root cause. I hand-wrote the Zod schema from memory instead of from the form. It validated nom, message and reference. The field the clinic actually calls people back on — telephone — wasn't in the schema, so it wasn't required, so nothing caught it. Optional by omission.
Prevention. Derive the schema from the type, not the other way around. Use .strict() so unknown keys are an error rather than a shrug. And write one test per field that asserts the absence of that field is rejected. Absence tests are the only tests that catch this bug class.

Mistake 5: security headers vanished on every middleware redirect

Symptom. Open /admin logged out, get bounced to /login, and the login page arrived with zero CSP. The moment a user is anonymous, my entire header layer disappeared.
Root cause. NextResponse.redirect() hands you a fresh response. My headers were set on the object I returned from the happy path only. The redirect branch returned early, without them. Middleware has more than one exit and I treated it like it had one.
Prevention. Build the headers once, then apply them through a single wrapper that every return goes through. No exit path is allowed to skip it. When you touch middleware, curl every response the middleware can produce — 200, 307, 401, 429 — not just the one you tested.

Mistake 6: I treated a client-side CSRF token like real protection

Symptom. My contact form generated a token in the browser, attached it to the body, and I wrote a section in the skill titled "CSRF Protection" as if that closed the hole. It didn't.
Root cause. Anything the browser computes, an attacker's page can also compute. A token generated client-side protects nothing -> the attacker just reads it off their own page and submits it. Same for a double-submit cookie check done from JavaScript. The real defence is that the server decides where a request came from, and the browser will not let a foreign page forge that answer. That's the Origin header, and it has to be compared against an allow-list server-side. SameSite cookies on the session cookie are the second layer. The token was decoration.
Prevention. Assume every value that arrives in a request body is attacker-controlled, including values your own front end computed. Allow-list with Zod, validate Origin server-side, and use SameSite=Lax or stricter on auth cookies. Never trust the client to enforce anything.

The six, in one table

MistakeBlast radiusPrevention
no-sniff instead of nosniffSilent. Every response. Content-type sniffing stayed open site-wide.Curl prod, diff header values against the spec character by character.
One shared rate-limit Map, then a leaking lazy sweepAll routes for an IP at once; then unbounded memory growth over time.Bucket per endpoint, one global timer sweep, hard cap on key count.
429 with no Retry-AfterMy own client turned a rate limit into a retry storm.Always send Retry-After plus the X-RateLimit-* trio.
Validator missing telephoneA week of unusable client requests, delivered as success.Derive the schema from the type, use .strict(), test every field's absence.
Headers lost on middleware redirectsEvery anonymous user hit unprotected pages. CSP gone on the login screen.One header builder, one wrapper, every return goes through it.
Client-side CSRF token as protectionZero. The token was attacker-readable by construction.Validate Origin against an allow-list server-side, SameSite cookies.

The code that actually shipped

Headers and CSP, centralised, with a per-request nonce and no early exit that skips the headers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// middleware.ts - Next.js 14 App Router
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
// WHY: fresh nonce every request. A static CSP string is decoration -
// an attacker only has to find one reflected value to bypass it.
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')

const csp = [
"default-src 'self'",
// WHY: 'strict-dynamic' lets the nonce carry trust to the chunks it loads.
// 'unsafe-inline' is deliberately absent.
"script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'",
// WHY: Tailwind v4 and Next inject style tags at runtime, so styles
// need the nonce too - otherwise CSP blocks my own UI.
"style-src 'self' 'nonce-" + nonce + "'",
"img-src 'self' blob: data: https:",
"font-src 'self' data:",
// WHY: Prisma talks to Supabase from the browser during auth.
"connect-src 'self' https://*.supabase.co",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
// WHY: clickjacking. 'none' also covers old browsers that ignore CSP.
"frame-ancestors 'none'",
].join('; ')

const headers = new Headers()
headers.set('Content-Security-Policy', csp)
// WHY: exactly 'nosniff'. One hyphen and the browser ignores the header.
headers.set('X-Content-Type-Options', 'nosniff')
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
headers.set('X-Frame-Options', 'DENY')
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains')
headers.set('x-nonce', nonce)

// WHY: one exit point for every branch. This is the fix for mistake 5 -
// NextResponse.redirect() starts EMPTY, so headers must be re-applied.
const finish = (res: NextResponse) => {
headers.forEach((value, key) => res.headers.set(key, value))
return res
}

const token = request.cookies.get('session')?.value
const isPrivate = request.nextUrl.pathname.startsWith('/admin')

if (isPrivate && !token) {
const login = new URL('/login', request.url)
login.searchParams.set('from', request.nextUrl.pathname)
return finish(NextResponse.redirect(login, 307))
}

return finish(NextResponse.next())
}

export const config = {
// WHY: exclude static assets. Running CSP on a 40 KB chunk is wasted work.
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
}
The rate limiter, per-endpoint, with a bounded map and a single global sweep:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// lib/rate-limit.ts
type Bucket = { count: number; resetAt: number }

const buckets = new Map<string, Bucket>()

const DEFAULT_WINDOW_MS = 60_000
const DEFAULT_MAX = 10
// WHY: a hard ceiling. Even if the sweep is broken, memory is bounded.
const MAX_KEYS = 5_000

// WHY: ONE timer for the whole map. A setTimeout per key is how you
// end up with 10,000 live handles in production and no idea why.
const sweeper: ReturnType<typeof setInterval> = setInterval(() => {
const now = Date.now()
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key)
}
}, DEFAULT_WINDOW_MS)

// WHY: unref only exists on Node handles; the edge runtime returns a number.
;(sweeper as { unref?: () => void }).unref?.()

export type Limit = { ok: boolean; remaining: number; retryAfter: number }

export function rateLimit(
key: string,
max = DEFAULT_MAX,
windowMs = DEFAULT_WINDOW_MS,
): Limit {
const now = Date.now()

// WHY: evict the oldest insertion when over budget. Map preserves
// insertion order, so this is a cheap FIFO - not a real LRU, but it
// keeps a flood of unique IPs from filling the heap.
if (buckets.size >= MAX_KEYS) {
const oldest = buckets.keys().next()
if (!oldest.done) buckets.delete(oldest.value)
}

const bucket = buckets.get(key) ?? { count: 0, resetAt: now + windowMs }

// WHY: roll the window instead of creating a new object, so the
// insertion order stays meaningful for the eviction above.
if (bucket.resetAt <= now) {
bucket.count = 0
bucket.resetAt = now + windowMs
}

bucket.count += 1
buckets.set(key, bucket)

return {
ok: bucket.count <= max,
remaining: Math.max(0, max - bucket.count),
// WHY: ceil, never floor. Rounding down tells clients to retry early.
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
}
}
The caller, returning a 429 a polite client can actually obey:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/api/contact/route.ts
import { NextResponse } from 'next/server'
import { rateLimit } from '@/lib/rate-limit'

export async function POST(request: Request) {
// WHY: the key includes the endpoint. Sharing one bucket across routes
// is exactly the mistake that let a form burst starve the dashboard.
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const limit = rateLimit('contact:' + ip, 5, 60_000)

// WHY: a 429 without Retry-After is a retry storm with extra steps.
if (!limit.ok) {
return NextResponse.json(
{ error: 'Too many requests. Try again shortly.' },
{
status: 429,
headers: {
'Retry-After': String(limit.retryAfter),
'X-RateLimit-Limit': '5',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(
Math.floor(Date.now() / 1000) + limit.retryAfter,
),
},
},
)
}

// ... Zod validate -> prisma -> Resend
}
And the validator, so the fourth field can never vanish again:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// lib/schemas.ts
import { z } from 'zod'

// WHY: .strict() turns an unrecognised key into a loud error instead of
// silently dropping data - which is how telephone went missing for a week.
export const contactSchema = z
.object({
nom: z.string().min(2).max(80),
// WHY: the field the clinic actually calls people back on.
telephone: z.string().min(6).max(20),
message: z.string().min(10).max(2000),
reference: z.string().min(1).max(40),
})
.strict()

export type Contact = z.infer<typeof contactSchema>

What I'd tell myself on day one

Build the boring thing first. Auth, validation, headers, rate limits, logging — boring, boring, boring, boring. Get it right, then make it fast. Speed > perfection, but only after the perfection is in the boring places.
Treat the panel as a security risk. An admin dashboard on a public hostname is a door with a sign on it. Every route on that domain is now part of your attack surface whether you wrote it or not.
Read the response headers on your own site. Curl it. Compare the values to the spec. Not because the framework is broken, but because no-sniff was mine, and it sat in production for weeks with a green checkmark next to it.
I still ship fast. I just read the diff before it deploys now.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article