HTTP-statuskoder — komplett guide för programmerare: alla statusar, tillämpningar och säkerhetsfällor
De flesta dokument om HTTP-statuskoder är listor med 63 poster och enradiga definitioner. Mycket kan inte utläsas från det. Det här dokumentet går motsatt väg — sju verkliga scenarier som du skriver i backend, varje med fullständig fungerande kod Fastify + Prisma, visad i två versioner: med säkerhetsbuggar och utan. HTTP-statuskoder dyker upp där de alltid dyker upp — i sammanhang. I slutet en referenstabell över alla 63 koder med RFC-tilldelning — när du behöver söka efter nummer.
1. REST API — CRUD för inlägg
Vi börjar med det vanligaste fallet. Vi skriver en endpoint som hanterar blogginlägg. I den kommer naturligtvis åtta HTTP-koder att dyka upp: 200, 201, 204, 304, 400, 404, 409, 422. Var och en på sin plats, inte för att "den var tvungen att användas", utan för att den löser ett konkret problem.
Fullständig endpoint — Fastify + Prisma
// apps/api/src/routes/posts.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import crypto from 'node:crypto'
import { prisma } from '../lib/prisma'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
publishAt: z.string().datetime().optional(),
category: z.enum(['tech', 'business', 'security']),
})
const UpdatePostSchema = CreatePostSchema.partial()
function etag(updatedAt: Date) {
return `"${crypto.createHash('sha1').update(updatedAt.toISOString()).digest('hex').slice(0, 16)}"`
}
export async function postsRoutes(fastify: FastifyInstance) {
// LIST — GET /posts
fastify.get('/posts', async (req, reply) => {
const posts = await prisma.post.findMany({
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 50,
})
return reply.send(posts) // 200 OK
})
// READ — GET /posts/:id (med ETag och 304-validering)
fastify.get<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
const currentEtag = etag(post.updatedAt)
if (req.headers['if-none-match'] === currentEtag) {
return reply.code(304).send() // utan innehål
}
return reply.header('ETag', currentEtag).send(post) // 200 OK med ETag
})
// CREATE — POST /posts
fastify.post('/posts', async (req, reply) => {
const parsed = CreatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ // 400 — dålig struktur
error: 'invalid_body',
issues: parsed.error.flatten(),
})
}
const data = parsed.data
// Affärslogik-validering — 422, för format var OK
if (data.publishAt && new Date(data.publishAt) < new Date()) {
return reply.code(422).send({
error: 'publish_at_in_past',
message: 'publishAt måste vara i framtiden',
})
}
try {
const post = await prisma.post.create({
data: {
title: data.title,
content: data.content,
category: data.category,
publishAt: data.publishAt ? new Date(data.publishAt) : null,
authorId: req.user.id,
},
})
return reply
.code(201) // 201 Created
.header('Location', `/posts/${post.id}`)
.header('ETag', etag(post.updatedAt))
.send(post)
} catch (err: any) {
if (err.code === 'P2002') { // Prisma unique constraint
return reply.code(409).send({ // 409 Conflict
error: 'slug_already_exists',
})
}
throw err
}
})
// UPDATE — PATCH /posts/:id (optimistisk concurrency via If-Match)
fastify.patch<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const parsed = UpdatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.flatten() })
}
const existing = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!existing) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (existing.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
// Optimistic lock — klienten skickade If-Match med ETag
const ifMatch = req.headers['if-match']
if (ifMatch && ifMatch !== etag(existing.updatedAt)) {
return reply.code(412).send({ // 412 Precondition Failed
error: 'stale_version',
message: 'Någon ändrade inlägget — hämta den aktuella versionen',
})
}
const updated = await prisma.post.update({
where: { id: existing.id },
data: parsed.data,
})
return reply.header('ETag', etag(updated.updatedAt)).send(updated) // 200 OK
})
// DELETE — DELETE /posts/:id
fastify.delete<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (post.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
await prisma.post.update({
where: { id: post.id },
data: { deletedAt: new Date() },
})
return reply.code(204).send() // 204 No Content
})
}
Varför dessa koder och inte andra — beslut efter beslut
Varför 201 + Location istället för 200
200 OK säger "operationen lyckades". 201 Created säger "den lyckades och en ny entitet skapades". Skillnaden är praktisk: en klient som får 201 med Location: /posts/abc vet exakt att den kan göra GET /posts/abc och få den nyligen skapade resursen. En klient som får 200 måste extrahera ID:t från kroppen — vilket kräver ett kontrakt. Kontraktet kan ändras och förstöra klienter; Location är standarden.
Varför 204 för DELETE istället för 200
Soft delete lyckades, vi har inget meningsfullt att returnera — 204 No Content. RFC 9110 förbjuder kropp vid 204, så fetch() i webbläsaren hanterar automatiskt denna respons som "ingen kropp" och försöker inte tolka JSON. Att returnera 200 med {"deleted":true} tvingar klienten att hantera ett sådant format; 204 är standarden.
Varför jag särskiljer 400 och 422
Zod returnerar två typer av fel. "Saknat fält title" — syntax, begärans format är dåligt, 400 Bad Request. "publishAt i det förflutna" — format är OK (det är ett giltigt ISO-datum), men domänlogiken vägrar. Det är 422 Unprocessable Content. GitHub, Stripe, Rails gör det konsekvent så här — och det är vettigt, för klienten behandlar 400 som en bugg (vi komponerar begäran felaktigt), medan 422 är en vägran (användaren försöker göra något affärsmässigt olagligt).
Varför 409 för unique constraint
Prisma kastar P2002 när två användare samtidigt infogar ett inlägg med samma slug. Kod 400 skulle vara vilseledande — begäran var korrekt. 422 skulle vara konstigt — det är inte en affärsregel, det är en tillståndskonflikt. 409 Conflict är exakt det: "jag kan inte göra det här, för det strider mot det aktuella tillståndet".
Varför 412 vid optimistic lock
If-Match är ett förutsättande villkor. När det inte uppfylls, säger specifikationen (RFC 9110 §13.1.1) tydligt: 412 Precondition Failed. Inte 409 — klienten är inte i en tillståndskonflikt, den hamnade själv i den för att den har en gammal version.
Varför 304 istället för att serva helt svar
En klient med ETag "abc123" skickar If-None-Match: "abc123". Om inlägget inte ändrats — returnera 304, tom kropp. Det sparar bandbredd (inlägget kan vara 50 KB innehål), det sparar JSON-serialisering, det sparar gzip. Cloudflare/Varnish förstår 304 och försöker inte cachea det som en svar. I en typisk CMS — 80%+ av förfrågningarna till publicerade inlägg kan sluta med 304.
Test som verifierar besluten
// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'
test('POST /posts returnerar 201 + Location', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: { title: 'X', content: 'Y', category: 'tech' },
})
expect(res.statusCode).toBe(201)
expect(res.headers.location).toMatch(/^\/posts\//)
expect(res.headers.etag).toBeTruthy()
})
test('POST /posts med publishAt i det förflutna → 422 (inte 400)', async () => {
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: {
title: 'X', content: 'Y', category: 'tech',
publishAt: '2020-01-01T00:00:00Z', // förflutet
},
})
expect(res.statusCode).toBe(422) // semantik, inte syntax
expect(res.json().error).toBe('publish_at_in_past')
})
test('DELETE returnerar 204 utan kropp', async () => {
const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
expect(res.statusCode).toBe(204)
expect(res.body).toBe('') // tom sträng, inte "{}"
})
test('GET med If-None-Match returnerar 304 utan kropp', async () => {
const first = await app.inject({ method: 'GET', url: '/posts/abc' })
const etag = first.headers.etag
const second = await app.inject({
method: 'GET', url: '/posts/abc',
headers: { 'if-none-match': etag },
})
expect(second.statusCode).toBe(304)
expect(second.body).toBe('')
})
test('PATCH med förfallen If-Match → 412', async () => {
const res = await app.inject({
method: 'PATCH', url: '/posts/abc',
headers: { 'if-match': '"gammal-version"' },
payload: { title: 'Ny' },
})
expect(res.statusCode).toBe(412)
})
2. Inloggning och sessioner — 4 verkliga buggar, 4 fixes
Inloggningsendpointen är en plats där fyra olika säkerhetsbuggar visar sig genom opretentiösa HTTP-kod-/svarformat-/loggningsbeslut. Nedan är den fullständiga vägen: först versionen med buggar (exakt sådan vi skriver på första iterationen), sedan varje bugg extraherad på bordet och fixad, sedan den slutliga versionen med test.
Version med buggar (ANVÄND INTE)
// apps/api/src/routes/auth/login.ts — version med 4 buggar
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const { email, password } = req.body as { email: string; password: string }
req.log.info({ email, password }, 'login attempt') // BUGG #4
const user = await prisma.user.findUnique({ where: { email } })
if (!user) { // BUGG #1
return reply.code(404).send({ error: 'user_not_found' })
}
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) { // BUGG #2
return reply.code(401).send({ error: 'wrong_password' })
}
const attempts = await redis?.incr(`login:${email}`).catch(() => null)
if (attempts === null) { // BUGG #3
// Redis kraschade — vi tillåter vidare, säkerhet < tillgänglighet
} else if (attempts > 5) {
return reply.code(429).send({ error: 'too_many' })
}
const token = fastify.jwt.sign({ userId: user.id })
return reply.send({ token })
})
}
Bugg #1 — användaruppräkning genom olika koder
Endpointen returnerar 404 user_not_found när e-postadressen inte finns, 401 wrong_password när den finns men lösenordet är fel. En angripare med en lista på 500 000 e-postadresser (läcka från en annan tjänst) filtrerar bort på några timmar alla aktiva konton för ditt företag. HTTP-statuskoder hamnar i proxy/CDN/access-loggar — du behöver inte ens läsa kroppen.
Fix:
// DUMMY_HASH — förkalkylerad bcrypt-hash av strängen "dummy" (kostnad 10)
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const user = await prisma.user.findUnique({ where: { email } })
const hashToCompare = user?.passwordHash ?? DUMMY_HASH
const ok = await bcrypt.compare(password, hashToCompare)
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
Nu får angriparen identisk 401 invalid_credentials oavsett om kontot existerar. Det finns inget sätt att skilja.
Bugg #2 — timing attack
Fixet från #1 verkar bara lösa problemet. Om du läser det noggrant — när user inte finns, hashToCompare = DUMMY_HASH, bcrypt.compare tar ~80 ms. När user finns, bcrypt jämför med dess riktiga hash — också ~80 ms (förutsatt samma kostnad). OK, i detta specifika fragment är tidningen utjämnad.
Men observera: i den ursprungliga versionen med buggar körs den första grenen if (!user) return ... på ~2 ms (bara databasfråga), den andra på ~82 ms (fråga + bcrypt). En angripare mäter genomsnittstiden från 100 försök:
// attacker.ts — visar skillnaden
async function measureLogin(email: string) {
const samples = 100
const start = Date.now()
for (let i = 0; i < samples; i++) {
await fetch('/login', { method: 'POST', body: JSON.stringify({ email, password: 'wrong' }) })
}
return (Date.now() - start) / samples
}
console.log('exists:', await measureLogin('real@company.pl')) // ~82ms
console.log('notexists:', await measureLogin('random@xxxxxx.pl')) // ~2ms
Skillnaden på 80 ms är en evighet — angriparen enumererar konton utan att titta på HTTP-statuskoden. Fixet kräver dummy bcrypt.compare för icke-existerande användare (se kod ovan — den är redan korrekt). Dessutom en regressions-test:
test('login avslöjar inte användarens existens genom timing', async () => {
const warmup = 10, samples = 200
// warmup
for (let i = 0; i < warmup; i++) await login('whatever@nowhere.xx', 'wrong')
const tExists: number[] = []
const tNotExists: number[] = []
for (let i = 0; i < samples; i++) {
const t1 = performance.now()
await login('real@company.pl', 'wrong')
tExists.push(performance.now() - t1)
const t2 = performance.now()
await login('random' + i + '@xxxxxx.xx', 'wrong')
tNotExists.push(performance.now() - t2)
}
const median = (arr: number[]) => arr.sort()[arr.length / 2 | 0]
const diff = Math.abs(median(tExists) - median(tNotExists))
expect(diff).toBeLessThan(5) // mindre än 5ms skillnad
})
Bugg #3 — fail-open rate limit
redis?.incr(...).catch(() => null) — när Redis kraschade, attempts === null, villkoret attempts > 5 är false, gränsen är inaktiverad. Verklig incident (från revision på PageForYou.pl i april 2026): OOM på Redis + angripare samtidigt + 200 000 lösenordsförsök innan någon märkte.
Fix — fail closed:
const key = `login:${email}:${req.ip}`
let attempts: number
try {
attempts = await redis.incr(key)
if (attempts === 1) await redis.expire(key, 60) // glidande fönster 1 min
} catch {
// Redis kraschade — vi tillåter INTE vidare
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.send({ error: 'too_many_attempts' })
}
Trade-off, medveten: om Redis kraschade kan ingen logga in i ~30 s (tid för Redis-återhämtning + expire). Vi accepterar det — säkerhet > tillgänglighet. Alternativet är en lokal fallback (in-memory-karta), men då har ett distribuerat system en okoordinerad gräns — varje pod räknar separat, reellt N× 5 försök istället för 5.
Bugg #4 — lösenord i loggar
req.log.info({ email, password }, ...) → Loki/ELK/Splunk får lösenordet i klartext. Alla med tillgång till övervakning (SRE, DevOps, underleverantör) ser det. Klassisk läcka.
Fix — Fastify logger redact:
import Fastify from 'fastify'
const fastify = Fastify({
logger: {
level: 'info',
redact: {
paths: [
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'req.headers.authorization',
'req.headers.cookie',
],
censor: '[REDACTED]',
},
},
})
Redact fungerar på Pino-serializernivå — lösenorden hamnar aldrig i transporten, inte ens av misstag någon annanstans i koden.
Slutlig version av endpointen
// apps/api/src/routes/auth/login.ts — efter alla 4 fixes
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { prisma } from '../../lib/prisma'
import { redis } from '../../lib/redis'
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(1) })
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const parsed = LoginSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body' })
}
const { email, password } = parsed.data
// 1. Rate limit (fail-closed)
const rateKey = `login:${email.toLowerCase()}:${req.ip}`
try {
const attempts = await redis.incr(rateKey)
if (attempts === 1) await redis.expire(rateKey, 60)
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'too_many_attempts' })
}
} catch {
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
// 2. Constant-time user lookup + compare
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } })
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH)
if (!user || !ok) {
return reply
.code(401)
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'invalid_credentials' })
}
if (user.disabledAt) {
return reply.code(403).send({ error: 'account_disabled' })
}
// Framgång — återställ rate limit + utfärda token
await redis.del(rateKey).catch(() => {})
const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
return reply.send({ token, expiresIn: 900 })
})
}
Varför 401 och inte 403 för misslyckad inloggning
Klassiskt misstag: eftersom användaren gett "fel lösenord", kanske 403 Forbidden? Nej. 401 betyder "jag vet inte vem du är, autentisera dig". 403 betyder "jag vet vem du är, men du har ingen behörighet till denna resurs". Vid inloggning misslyckades autentisering — 401. Jag använder 403 bara för inaktiverat konto, där identitet var OK, men policy vägrar vidare.
3. Omdirigeringar — 5 scenarier utan att skjuta sig själv i foten
Fem olika omdirigeringar, var och en med en annan anledning. Valet av fel kod ändrar POST till GET, bryter SEO, eller öppnar open redirect.
Scenario 1: HTTP → HTTPS (globalt)
Kod: 301 + HSTS. Permanent, för alla metoder (GET dominerar; för POST är det lika många reparationer, webbläsaren gör vad den ska göra).
# /etc/nginx/sites-enabled/example.pl
server {
listen 80;
server_name example.pl www.example.pl;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.pl www.example.pl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# ...
}
Fälla: den första HTTP-begäran kan innehålla sessionscookies i klartext. HSTS löser detta först från det andra besöket — efter ett första lyckat HTTPS-svar kommer webbläsaren ihåg och försöker inte längre HTTP. Det första besöket måste antingen via explicit https://, eller preload-lista (hstspreload.org).
Scenario 2: Gammal URL → ny efter omvarumärkesnamn
Kod: 301. Google kommer att avindexera den gamla URL:en och skriva över sidrangordningen.
fastify.get('/blog-old/:slug', (req, reply) => {
return reply.redirect(301, `/blog/${req.params.slug}`)
})
Fälla: 301 cachas aggressivt av webbläsaren (ibland för alltid). Om du missar mål-URL:en kommer användare att landa där även efter att du fixat det, tills de rensar cache. Om du inte är 100% säker — använd 302 från början, ändra till 301 när du är säker.
Scenario 3: Post-Redirect-Get efter formulär
Kod: 303 See Other. Tvingar GET istället för POST — F5 på bekräftelsesidan orsakar inte duplicerad order.
fastify.post('/checkout', async (req, reply) => {
const order = await createOrder(req.body)
return reply.code(303).header('Location', `/orders/${order.id}`).send()
})
Skillnad från 302: 302 avsåg historiskt att "bevara metod", men webbläsare ändrade alltid POST till GET. RFC 9110 dokumenterade denna avvikelse. 303 är explicit om det — ingen tvetydlighet.
Scenario 4: Permanent förändring av API-endpoint från POST
Kod: 308 Permanent Redirect. Till skillnad från 301, garanterar bevarande av metod och kropp.
// Gammal endpoint /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
return reply.redirect(308, '/api/v2/orders')
})
En klient som skickade POST med 2 KB-kropp får 308 Location: /api/v2/orders och utför POST med samma kropp. 301 är INTE SÄKERT här — vissa HTTP-bibliotek (äldre curl, gamla axios) konverterar POST till GET.
Scenario 5: Open redirect — klassisk phishing-attack
Vanligaste sårbarheten jag ser på revision. Endpoint för spårning av klick:
// SÅRBAR — ANVÄND INTE
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
return reply.redirect(302, req.query.url)
})
Attack: SMS "Ditt bank.pl-konto kommer att blockeras, verifiera: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Offret ser en betrodd domän, klickar, får 302 Location: https://bank-login-verify.evil/auth, landar på phishing. Inmatning av lösenord = komprometterat konto.
Fix — allowlist:
const ALLOWED_HOSTS = new Set([
'bank.pl', 'mobile.bank.pl', 'blog.bank.pl',
])
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
let target: URL
try {
target = new URL(req.query.url)
} catch {
return reply.code(400).send({ error: 'invalid_url' })
}
// Endast HTTPS, endast kända värdar
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
return reply.code(400).send({ error: 'domain_not_allowed' })
}
return reply.redirect(302, target.toString())
})
Valet av 302 (inte 301) är avsiktligt — vi vill inte att webbläsaren cachear "klicka här → landa där", det ska vara ett friskt beslut vid varje klick.
4. Upload och range requests — inklusive Apache Killer
Upload av fil är en plats där 413 (fil för stor), 415 (fel typ), 416 (ogiltigt intervall) och 206 (partial content — video med sökning) dyker upp. Plus Apache Killer — 15-årig attack som fortfarande fungerar på felkonfigurerade servrar.
Upload-endpoint med validering
import multipart from '@fastify/multipart'
import { pipeline } from 'node:stream/promises'
import fs from 'node:fs'
import crypto from 'node:crypto'
const ALLOWED_MIME = new Set([
'image/jpeg', 'image/png', 'image/webp',
'application/pdf',
])
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
export async function uploadRoute(fastify: FastifyInstance) {
await fastify.register(multipart, {
limits: {
fileSize: MAX_SIZE,
files: 1,
fields: 5,
headerPairs: 200,
},
})
fastify.post('/upload', async (req, reply) => {
const data = await req.file()
if (!data) return reply.code(400).send({ error: 'no_file' })
if (!ALLOWED_MIME.has(data.mimetype)) {
return reply.code(415).send({ // 415 Unsupported Media Type
error: 'mime_not_allowed',
allowed: [...ALLOWED_MIME],
})
}
const id = crypto.randomBytes(16).toString('hex')
const ext = data.mimetype.split('/')[1]
const path = `/srv/uploads/${id}.${ext}`
try {
await pipeline(data.file, fs.createWriteStream(path))
} catch (err: any) {
if (err.code === 'FST_REQ_FILE_TOO_LARGE') {
return reply.code(413).send({ // 413 Content Too Large
error: 'file_too_large',
maxBytes: MAX_SIZE,
})
}
throw err
}
// Sanity check — multipart-gränsen kan kringgås, kontrollera real size
const stat = await fs.promises.stat(path)
if (stat.size > MAX_SIZE) {
await fs.promises.unlink(path)
return reply.code(413).send({ error: 'file_too_large' })
}
// Magic bytes check — mimetype från header kan ljuga
const realMime = await detectMimeFromBytes(path) // t.ex. file-type lib
if (!ALLOWED_MIME.has(realMime)) {
await fs.promises.unlink(path)
return reply.code(415).send({ error: 'mime_mismatch' })
}
return reply.code(201)
.header('Location', `/files/${id}`)
.send({ id, size: stat.size, mime: realMime })
})
}
Varför magic bytes check — förlita dig inte på klienten
data.mimetype är Content-Type från multipart-headern — klienten kan skriva vilken sträng som helst där. En angripare skickar .exe med Content-Type: image/png, och senare på andra ställen i applikationen serveras denna fil till img src — vilket inte är farligt — men till iframe eller Content-Type i svaret, och webbläsaren exekverar binären. Verifiering via file-type/libmagic läser de första bytena och känner igen typen verkligen.
Range requests — serwering av video med sökning
Videospelaren skickar Range: bytes=1048576-2097151 — den vill ha mittfragmentet. Servern svarar 206 Partial Content.
fastify.get<{ Params: { id: string } }>('/videos/:id', async (req, reply) => {
const video = await prisma.video.findUnique({ where: { id: req.params.id } })
if (!video) return reply.code(404).send({ error: 'not_found' })
const stat = await fs.promises.stat(video.path)
const range = req.headers.range
if (!range) {
return reply
.header('Content-Length', stat.size.toString())
.header('Accept-Ranges', 'bytes')
.type('video/mp4')
.send(fs.createReadStream(video.path)) // 200 OK, helhet
}
const match = /^bytes=(\d+)-(\d*)$/.exec(range)
if (!match) {
return reply.code(416).send({ error: 'invalid_range' })
}
const start = parseInt(match[1], 10)
const end = match[2] ? parseInt(match[2], 10) : stat.size - 1
if (start >= stat.size || end >= stat.size || start > end) {
return reply
.code(416)
.header('Content-Range', `bytes */${stat.size}`)
.send()
}
const chunkSize = end - start + 1
return reply
.code(206) // 206 Partial Content
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', chunkSize.toString())
.type('video/mp4')
.send(fs.createReadStream(video.path, { start, end }))
})
Apache Killer — CVE-2011-3192
Angriparen skickar ett header med tusentals överlappande intervall:
Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300
Servern försöker förbereda och slå samman alla dessa fragment — OOM, crash. Fixet i Apache/Nginx fastnade redan för länge sedan, men om du skriver din egen Range-hantering (ovan) — måste du begränsa antalet och storleken på intervall. Min handler accepterar bara ett enskilt intervall, vilket är säkert. Fullständig RFC 7233 stöder multipart/byteranges med flera intervall — implementera bara om du verkligen behöver det, med gränsen max 10 intervall.
5. Async jobs — 202 Accepted + polling
GDPR-export ZIP har ~62 sekunder på stor konto (PageForYou GDPR-export). Synkront — webbläsaren returnerar timeout, Cloudflare 524. Korrekt — 202 Accepted + statusendpoint + polling från klientsida.
Endpoint som skapar job
import { Queue } from 'bullmq'
const exportQueue = new Queue('exports', { connection: redisConfig })
fastify.post('/exports', async (req, reply) => {
const job = await exportQueue.add('generate-rodo-zip', {
userId: req.user.id,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
})
return reply
.code(202) // 202 Accepted
.header('Location', `/exports/${job.id}`)
.send({
jobId: job.id,
status: 'queued',
statusUrl: `/exports/${job.id}`,
})
})
Statusendpoint — vad ska returneras, beroende på fas
fastify.get<{ Params: { id: string } }>('/exports/:id', async (req, reply) => {
const job = await exportQueue.getJob(req.params.id)
if (!job) return reply.code(404).send({ error: 'job_not_found' })
// Auktorisering — endast jobbets ägare
if (job.data.userId !== req.user.id) {
return reply.code(404).send({ error: 'job_not_found' }) // 404 avsiktligt, inte 403
}
const state = await job.getState()
if (state === 'completed') {
// Klar — omdirigering till download URL
return reply.code(303).header('Location', job.returnvalue.downloadUrl).send()
}
if (state === 'failed') {
return reply.code(500).send({
error: 'job_failed',
attempts: job.attemptsMade,
failedReason: job.failedReason,
})
}
// Fortfarande körs
return reply
.code(200)
.header('Retry-After', '5') // tips till klient — poll efter 5s
.send({
jobId: job.id,
status: state, // waiting, active, delayed
progress: job.progress,
})
})
Varför 404 istället för 403 på andras job
En angripare som känner till jobbets ID-format kunde enumerera /exports/0, /exports/1, ... och efter status (403 vs 404) kartlägga annan användaraktivitet. Genom att alltid returnera 404 när jobbet inte är ditt — kan angriparen inte skilja mellan "existerar inte" och "inte ditt".
Varför 303 när det är klart, inte 200
En klient som frågar efter status vill ha URL till nedladdning när den är klar. 303 See Other med Location: signed-url säger "gå dit GET-em". Klienten kan automatiskt följa omdirigering, användare får filen. 200 med { downloadUrl: "..." } kräver att klienten gör en separat GET och separat logik — 303 är renare.
6. Cache-validering — ETag och 304
Fullständigt exempel på cache-validering i blog-API, som den du läser. Mål: minska bandbredd 10×, minska SSR-renderningstid.
Endpoint med ETag och validering
import crypto from 'node:crypto'
function computeEtag(post: { id: string; updatedAt: Date; version: number }) {
const raw = `${post.id}:${post.updatedAt.toISOString()}:${post.version}`
return `"${crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16)}"`
}
fastify.get<{ Params: { slug: string } }>('/posts/slug/:slug', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { slug: req.params.slug, status: 'published' },
select: { id: true, updatedAt: true, version: true, /* och resten av fälten */
title: true, content: true, excerpt: true, coverImage: true },
})
if (!post) return reply.code(404).send({ error: 'not_found' })
const currentEtag = computeEtag(post)
const ifNoneMatch = req.headers['if-none-match']
// Cache-validering — har klienten aktuell version?
if (ifNoneMatch === currentEtag) {
return reply
.code(304) // 304 Not Modified
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.send() // tom kropp
}
return reply
.code(200)
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.header('Vary', 'Accept-Language')
.send(post)
})
Hur detta används av Next.js / Cloudflare
Next.js ISR med revalidate: 60 anropar fetch till ditt API var 60:e sekund. Med ETag:
- Första fetch →
200 OK+ ETag + kropp (50 KB) - Efterföljande revalidation-fetch (var 60:e sekund) →
If-None-Match: "abc"→304+ 0 byte
Besparelse: ~50 KB × antal revalidation × antal servrar. Med 10 Cloudflare-regioner × 60 revalidation/h = 600 fetch/h/inlägg. 50 KB × 600 = 30 MB/h → utan ETag. Med ETag → 600 × ~200 B (bara header) = 120 KB/h. 250× mindre.
Vad MÅSTE ingå i ETag
Varje ändring som kan observeras av klienten. Minimum:
id— olika inlägg har olika etags även omupdatedAtråkar vara identiskupdatedAt— bump vid redigeringversion— vid kaskadändringar (kommentarer, vyräknare) är det vanligtvis bättre att ha ett eget fält än förlita sig påupdatedAt
Vad INTE ska sättas i ETag: hash av lösenord, hemligheter, andra användares data. ETag är offentlig — hamnar i CDN-loggar, webbläsarens headers.
7. Felhantering i produktion (500, 502, 503, 504)
Dessa fyra koder ser likadana ut, men var och en betyder något olika och var och en har olika reaktion från klientsida / övervakning.
500 — din kod kraschade
Undantag i handler. Inställning som inte läcker stack trace på prod och ger requestId för korrelation:
fastify.setErrorHandler((err, req, reply) => {
req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')
// Affärsfel — skicka vidare
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({
error: err.code || 'error',
message: err.message,
})
}
// Verklig 500 — göm detaljer
return reply.code(500).send({
error: 'internal_error',
requestId: req.id, // till loggar
// INTE: err.message, err.stack, err.sql, filvägar
})
})
När användare rapporterar "jag fick ett fel", frågar "vilket request ID?" och hittar i loggar en fullständig stack trace. Användare ser ingenting känsligt.
502 och 504 — Nginx → backend
Båda automatiska från Nginx. 502 = backend returnerade skräp (upstream kraschade under tiden, reset connection, ogiltigt HTTP-svar). 504 = backend svarade inte i tid (proxy_read_timeout).
# /etc/nginx/sites-enabled/api
upstream api {
server 127.0.0.1:4000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:4001 backup;
}
server {
listen 443 ssl;
server_name api.example.pl;
location / {
proxy_pass http://api;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Försök på nästa upstream
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# Ta bort läckade headers
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
}
503 — tillfälligt, Retry-After
503 är DEN ENDA från 5xx-gruppen som tydligt säger till klienten "försök igen". Använd när:
- Planerat underhåll (deploy blue-green)
- Circuit breaker öppen (beroende tjänst kraschade)
- Load shedding (för mycket RPS, skydda resten)
Vanligtvis med Retry-After + custom body:
// Circuit breaker — när DB är överbelastad
fastify.addHook('preHandler', async (req, reply) => {
if (await circuitBreaker.isOpen()) {
return reply
.code(503)
.header('Retry-After', '30')
.send({
error: 'service_degraded',
retryAfter: 30,
})
}
})
Nyckelskillnad 500 vs 503: 500 = bugg, SRE får page. 503 = förväntat, övervakningen skiljer (ingen larmering). Att returnera 503 istället för 500 för att "inte larma" — anti-mönster. Larm är till för att larma.
8. Referenstabell — alla 63 koder med RFC-tilldelning
När du snabbt behöver kontrollera en viss kod — tabellen nedan. Alla 63 officiella poster från IANA-registret med hänvisning till definierade RFC.
1xx — Informativ (4 koder)
| Kod | Namn | RFC | När |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Klient skickade Expect: 100-continue, server godkänner kropp |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket handshake, HTTP/2 upgrade |
| 102 | Processing ⚠ | RFC 2518 (inaktuell) | WebDAV, borttaget från RFC 4918 |
| 103 | Early Hints | RFC 8297 | Preload hint före slutgiltig svar (Chrome 103+) |
2xx — Framgång (10 koder)
| Kod | Namn | RFC | När |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Framgång med kropp |
| 201 | Created | RFC 9110 §15.3.2 | Ny resurs, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Async job köad |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Svar från omvandlande proxy |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, ingen kropp |
| 205 | Reset Content | RFC 9110 §15.3.6 | Återställ formulär (sällsynt) |
| 206 | Partial Content | RFC 9110 §15.3.7 | Range request |
| 207 | Multi-Status | RFC 4918 §11.1 | WebDAV batch |
| 208 | Already Reported | RFC 5842 §7.1 | WebDAV binding, undvik duplikat |
| 226 | IM Used | RFC 3229 | Delta encoding (sällsynt) |
3xx — Omdirigeringar (9 koder)
| Kod | Namn | RFC | När |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Flera representationer (sällsynt) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Permanent, GET (kan ändra POST→GET) |
| 302 | Found | RFC 9110 §15.4.3 | Tillfällig, GET (tvetydig) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Cache-validering ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (inaktuell) | Säkerhetssårbarhet, använd inte |
| 306 | (Oanvänd) | RFC 9110 §15.4.7 | Reserverad |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Tillfällig, bevarar metod |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Permanent, bevarar metod |
4xx — Klientfel (28 koder)
| Kod | Namn | RFC | När |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Dåligt requestformat |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Saknad / dålig token, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Reserverad, Stripe "kort avslaget" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Token OK, ingen behörighet |
| 404 | Not Found | RFC 9110 §15.5.5 | Resursen existerar inte |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Content negotiation passar inte |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Som 401 för proxy |
| 408 | Request Timeout | RFC 9110 §15.5.9 | För långsamt att skicka kropp |
| 409 | Conflict | RFC 9110 §15.5.10 | Unique violation, state machine |
| 410 | Gone | RFC 9110 §15.5.11 | Borttagen för alltid |
| 411 | Length Required | RFC 9110 §15.5.12 | Saknar Content-Length |
| 412 | Precondition Failed | RFC 9110 §15.5.13 | If-Match / If-Unmodified-Since |
| 413 | Content Too Large | RFC 9110 §15.5.14 | Kropp överskrider gränsen |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL för lång |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Dålig Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Dålig Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Uppfyller inte Expect |
| 418 | I'm a teapot | RFC 2324 (skämt) | Honeypot för skannrar |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 connection coalescing |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Affärsvalidering |
| 423 | Locked | RFC 4918 §11.3 | WebDAV |
| 424 | Failed Dependency | RFC 4918 §11.4 | WebDAV |
| 425 | Too Early | RFC 8470 | TLS 1.3 0-RTT replay protection |
| 426 | Upgrade Required | RFC 9110 §15.5.22 | Tvinga nyare protokoll |
| 428 | Precondition Required | RFC 6585 §3 | Tvinga If-Match (lost update) |
| 429 | Too Many Requests | RFC 6585 §4 | Rate limit, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Headers överskrider gränsen |
| 451 | Unavailable For Legal Reasons | RFC 7725 | GDPR, DMCA, geoblock |
5xx — Serverfel (11 koder)
| Kod | Namn | RFC | När |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Undantag i kod |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Okänd HTTP-metod |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Proxy fick skräp från backend |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Tillfällig, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Proxy → backend timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Dålig HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Content negotiation-fel (sällsynt) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | Inget diskutrymme (upload) |
| 508 | Loop Detected | RFC 5842 §7.2 | WebDAV recursion |
| 510 | Not Extended ⚠ | RFC 2774 (inaktuell) | HTTP Extension Framework |
| 511 | Network Authentication Required | RFC 6585 §6 | Captive portal (WiFi hotell/flygplats) |
Källor
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Juni 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Juni 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, April 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Februari 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, December 2017
- CVE-2011-3192, Apache HTTP Server Range Header DoS (Apache Killer), nvd.nist.gov
- OWASP, Authentication Cheat Sheet, cheatsheetseries.owasp.org
- OWASP, Unvalidated Redirects and Forwards, cheatsheetseries.owasp.org
- J. Kettle, Practical Web Cache Poisoning, PortSwigger Research 2018
- Fastify, Reply API documentation, fastify.dev/docs
- Node.js, BullMQ job queue documentation, docs.bullmq.io