Fix the HTTP status codes that cause real bugs: 401 vs 403, redirect method changes, 429 retries, 200-for-errors, and 400 vs 422.

Most HTTP status code guides list all 60+ codes alphabetically. This one covers the five that developers consistently get wrong in production. If you need a spec-grounded lookup while reading, keep SnipKit’s HTTP Status Reference open in another tab.
These mistakes are not academic. They create real bugs at the boundary between your app and every client, proxy, cache, browser, SDK, load balancer, and monitoring system that talks to it.
A few examples:
401 and sends the user to a login screen, even though they’re already logged in and just lack permissions.302 after a form POST and silently converts it to a GET, dropping the body you expected to preserve.429, gets no Retry-After, and immediately retries in a tight loop.200 OK with { "success": false }, so dashboards stay green while users see failures.400, making it impossible for clients to distinguish malformed input from domain-level rejection.Status codes are part of the protocol contract. If the code is wrong, the body often doesn’t get a chance to fix it because many clients act on the status line first.
Two pairs account for a huge share of production mistakes because the names are misleading and framework defaults encourage the wrong habit.
The common mistake is returning 401 Unauthorized for a user who is already authenticated but doesn’t have permission to access the route.
That’s wrong. In HTTP terms:
401 means the request has no valid credentials, or the credentials are expired/invalid.403 means the credentials are valid, but the user is not allowed to perform this action.Use these exactly as follows:
// User not logged in at all → 401
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
// User logged in but lacks admin role → 403
HTTP/1.1 403 Forbidden
// Secret resource — hide its existence from unauthorized users → 404
HTTP/1.1 404 Not FoundThat WWW-Authenticate header matters. If you’re using Bearer auth, Basic auth, or another challenge-based scheme, 401 should tell the client how to authenticate. A bare 401 with no challenge is often incomplete.
The practical rule:
401403404That last case is the nuance many teams miss. Suppose /admin/users/42/export should only be visible to a tiny group of operators. Returning 403 confirms the resource exists. Returning 404 hides existence entirely. If your security model requires non-disclosure, 404 is the safer answer.
Here’s the wrong Express pattern and the corrected version:
import express from "express";
const app = express();
function requireAdminWrong(req, res, next) {
const user = req.user;
// WRONG: user is authenticated but lacks permission
if (user && user.role !== "admin") {
return res.status(401).json({ error: "unauthorized" });
}
next();
}
function requireAdminCorrect(req, res, next) {
const user = req.user;
if (!user) {
res.set("WWW-Authenticate", 'Bearer realm="api"');
return res.status(401).json({ error: "authentication_required" });
}
if (user.role !== "admin") {
return res.status(403).json({ error: "forbidden" });
}
next();
}
app.get("/admin", requireAdminCorrect, (req, res) => {
res.json({ ok: true });
});And if the route should be concealed rather than merely denied:
function requireAdminHidden(req, res, next) {
const user = req.user;
if (!user || user.role !== "admin") {
return res.status(404).json({ error: "not_found" });
}
next();
}If your clients currently redirect to login on every 401, misusing 401 for authorization failures creates a broken UX loop immediately.
The second trap is redirecting with 302 because your framework made it the default and everyone calls redirect() by reflex.
The actual problem is method mutation. Browsers historically treat 302 in a way that often converts a POST into a GET on the redirected URL. That behavior was once technically wrong per the original spec, but it became so common that later specifications aligned with reality.
Here’s the exact failure mode:
// BAD: 302 redirect on a POST — browser converts to GET
POST /checkout → 302 Found → Location: /order-complete
// Browser sends: GET /order-complete (silently dropped the POST body)
// CORRECT: 307 preserves the method
POST /checkout → 307 Temporary Redirect → Location: /order-complete
// Browser sends: POST /order-complete (body preserved)If you redirect a form submission, webhook receiver, or API POST with 302, you may accidentally drop the request body and change semantics without noticing. That bug is nasty because everything looks “fine” in the browser until side effects disappear.
Use this decision matrix instead:
| Scenario | Status |
|---|---|
| Permanent, method can change | 301 |
| Permanent, method must stay | 308 |
| Temporary, method can change | 302 |
| Temporary, method must stay | 307 |
That “method can change” clause is the whole point. For many application flows, method absolutely does matter.
A minimal Node example makes the difference obvious:
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/checkout") {
// Choose carefully:
// res.writeHead(302, { Location: "/order-complete" }); // may become GET
res.writeHead(307, { Location: "/order-complete" }); // preserves POST
return res.end();
}
if (req.url === "/order-complete") {
let body = "";
req.on("data", chunk => (body += chunk));
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
method: req.method,
body
}));
});
return;
}
res.writeHead(404);
res.end();
});
server.listen(3000, () => {
console.log("http://localhost:3000");
});If you POST JSON to /checkout, a 307 will preserve the method and body to /order-complete. A 302 may not.
The next three errors show up most often in APIs because they seem harmless at implementation time and painful everywhere else.
Retry-After is uselessReturning 429 Too Many Requests is only half the job. Without Retry-After, the client doesn’t know when it should try again.
That forces bad client behavior:
A correct 429 looks like this:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"retry_after_seconds": 30
}Retry-After also accepts an HTTP-date:
Retry-After: Wed, 13 Apr 2026 14:30:00 GMTAnd yes, the same header also applies to 503 Service Unavailable, which is useful for maintenance windows or temporary overload.
Here’s a practical Express middleware example:
import express from "express";
const app = express();
const requests = new Map();
const WINDOW_MS = 60_000;
const LIMIT = 5;
app.use((req, res, next) => {
const ip = req.ip;
const now = Date.now();
const entry = requests.get(ip) ?? { count: 0, resetAt: now + WINDOW_MS };
if (now > entry.resetAt) {
entry.count = 0;
entry.resetAt = now + WINDOW_MS;
}
entry.count += 1;
requests.set(ip, entry);
if (entry.count > LIMIT) {
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
res.set("Retry-After", String(retryAfterSeconds));
return res.status(429).json({
error: "rate_limit_exceeded",
retry_after_seconds: retryAfterSeconds
});
}
next();
});
app.get("/", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);When you need to confirm edge cases like 429, 503, or less common redirect semantics, SnipKit’s HTTP Status Reference is useful because it keeps the protocol meaning and common usage in one searchable place.
This one survives in older APIs because it feels convenient: always return 200, then put the real outcome in the JSON body.
It’s also one of the fastest ways to make your system harder to integrate, monitor, cache, and debug.
The broken pattern:
// WRONG — breaks every HTTP client and monitoring tool
HTTP/1.1 200 OK
{ "success": false, "error": "User not found" }The correct pattern:
// CORRECT
HTTP/1.1 404 Not Found
{ "error": "user_not_found", "message": "No user with that ID exists" }Why this matters in practice:
fetch() and many client libraries branch on response.okIf you hide errors inside 200, every downstream tool has to parse your custom body format just to know whether the request worked. Many won’t.
A simple route example:
import express from "express";
const app = express();
const users = new Map([
["1", { id: "1", name: "Ada" }]
]);
app.get("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
return res.status(404).json({
error: "user_not_found",
message: "No user with that ID exists"
});
}
res.json(user);
});
app.listen(3000);The body still matters. A good error payload is structured and machine-readable. But the HTTP status must carry the top-level truth.
Developers often collapse all invalid input into 400 Bad Request. That loses an important distinction.
Use 400 when the request itself is malformed. Use 422 Unprocessable Content when the request is syntactically valid but fails domain rules or business validation.
The canonical contrast looks like this:
// 400: malformed request — JSON doesn't parse
HTTP/1.1 400 Bad Request
{ "error": "invalid_json" }
// 422: valid JSON, fails business rule (email already taken)
HTTP/1.1 422 Unprocessable Content
{
"error": "validation_failed",
"fields": { "email": "already registered" }
}That difference is especially useful on POST /users or PATCH endpoints.
Consider this complete example:
import express from "express";
const app = express();
app.use(express.json());
const emails = new Set(["taken@example.com"]);
app.post("/users", (req, res) => {
const { email, name } = req.body;
if (typeof email !== "string" || typeof name !== "string") {
return res.status(422).json({
error: "validation_failed",
fields: {
...(typeof email !== "string" ? { email: "must be a string" } : {}),
...(typeof name !== "string" ? { name: "must be a string" } : {})
}
});
}
if (emails.has(email)) {
return res.status(422).json({
error: "validation_failed",
fields: { email: "already registered" }
});
}
const user = {
id: crypto.randomUUID(),
email,
name
};
emails.add(email);
res.status(201).json(user);
});
// Express's built-in JSON parser will throw on malformed JSON.
// This error handler converts that parse failure into 400.
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && "body" in err) {
return res.status(400).json({ error: "invalid_json" });
}
next(err);
});
app.listen(3000);The practical test is simple:
400422That distinction helps client teams build better UX. A 400 often means “your request formatter is broken.” A 422 means “the user can fix this field and resubmit.”
Here’s a small Express app that applies all five corrections in one place: auth, redirects, rate limits, proper error statuses, and 400 vs 422.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const users = new Map([["1", { id: "1", name: "Ada", role: "user" }]]);
const emails = new Set(["taken@example.com"]);
const requestCounts = new Map();
app.use((req, res, next) => {
const key = req.ip;
const now = Date.now();
const windowMs = 60_000;
const limit = 3;
const entry = requestCounts.get(key) ?? { count: 0, resetAt: now + windowMs };
if (now > entry.resetAt) {
entry.count = 0;
entry.resetAt = now + windowMs;
}
entry.count++;
requestCounts.set(key, entry);
if (entry.count > limit) {
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
res.set("Retry-After", String(retryAfterSeconds));
return res.status(429).json({
error: "rate_limit_exceeded",
retry_after_seconds: retryAfterSeconds
});
}
next();
});
app.use((req, res, next) => {
const auth = req.get("Authorization");
if (auth === "Bearer admin-token") req.user = { id: "99", role: "admin" };
else if (auth === "Bearer user-token") req.user = { id: "1", role: "user" };
next();
});
app.get("/admin", (req, res) => {
if (!req.user) {
res.set("WWW-Authenticate", 'Bearer realm="api"');
return res.status(401).json({ error: "authentication_required" });
}
if (req.user.role !== "admin") {
return res.status(403).json({ error: "forbidden" });
}
res.json({ secret: true });
});
app.get("/secret-report", (req, res) => {
if (!req.user || req.user.role !== "admin") {
return res.status(404).json({ error: "not_found" });
}
res.json({ report: "classified" });
});
app.post("/checkout", (req, res) => {
res.redirect(307, "/order-complete");
});
app.all("/order-complete", (req, res) => {
res.json({ method: req.method, body: req.body });
});
app.get("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
return res.status(404).json({
error: "user_not_found",
message: "No user with that ID exists"
});
}
res.json(user);
});
app.post("/users", (req, res) => {
const { email } = req.body;
if (typeof email !== "string") {
return res.status(422).json({
error: "validation_failed",
fields: { email: "must be a string" }
});
}
if (emails.has(email)) {
return res.status(422).json({
error: "validation_failed",
fields: { email: "already registered" }
});
}
const user = { id: crypto.randomUUID(), email };
emails.add(email);
res.status(201).json(user);
});
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && "body" in err) {
return res.status(400).json({ error: "invalid_json" });
}
res.status(500).json({ error: "internal_server_error" });
});
app.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});This is the useful mental model to keep:
401403 or 404 if existence must be hidden307 or 308429 plus Retry-After422400200WWW-Authenticate and Retry-After.Status codes are a protocol, not a convention. The correct code makes your API self-documenting and gives clients the information they need to handle errors correctly. Before you return a code by habit, look it up in the HTTP Status Reference.