Understanding HTTP 403 Forbidden: Authorization, the 401 vs 403 Trap, and When to Hide a Resource Entirely
403 Forbidden is the server's way of saying "I know exactly who you are, and the answer is still no." That's the part people miss. A 403 isn't a login problem — re-authenticating, refreshing a token, or trying different credentials won't change anything. The resource exists, the server understood the request, and it is refusing to authorize it. The most common mistake with 403 is reaching for it (or for 401) at the wrong moment, so let's pin down what it really means, how it differs from the codes it's forever confused with, when you should hide the resource entirely with a 404 instead, and how to return and handle it without leaking information or breaking clients.
What Is a 403?
A 403 means the server understood your request and knows who you are, but refuses to authorize it. Unlike a 401, there's nothing the client can send to fix it — you're simply not allowed.
The 403 (Forbidden) status code indicates that the server understood the request but refuses to authorize it. A server that wishes to make public why the request has been forbidden can describe that reason in the response payload. — RFC 9110, Section 15.5.4
In plain English: "I understood you, I'm not confused about who you are, but you can't have this." The fix for a 403 is never "try again with credentials" — it's "get different permission": a higher role, a paid plan, an allow-listed IP, access from a permitted region. That's the whole personality of the code, and it's why mixing it up with 401 causes so much grief.
403 Is About Authorization, Not Authentication
This is the trap, and it's worth stating bluntly because the entire industry trips over it. A server asks two separate questions about a request:
- Authentication — Who are you? Can you prove your identity? (That's 401's domain.)
- Authorization — Are you allowed? Does your proven identity have permission? (That's 403's domain.)
A 401 says you failed the first question — you sent no credentials, or they were invalid or expired. A 403 says you passed the first question and failed the second. The server knows who you are; you just don't have the rights.
The mental model that keeps it straight:
- 401 = "Who are you?" — You haven't proven it. Authenticating can fix this.
- 403 = "I know who you are, and no." — You're authenticated fine; you lack permission. Authenticating again cannot fix this.
A quick gut check: if logging in or sending a fresh token could plausibly resolve the situation, it's a 401. If the user is already exactly who they claim to be and still can't proceed, it's a 403. A logged-out user hitting a protected page is a 401 (they could log in). A logged-in free-tier user hitting a premium feature is a 403 (logging in again changes nothing).
403 vs 401 vs 404 vs 407
Four codes orbit the idea of "you can't have this," and they're routinely swapped. Choose deliberately:
| Code | Meaning | Use when |
|---|---|---|
| 401 Unauthorized | Not authenticated | No credentials, or they're invalid/expired — logging in could fix it |
| 403 Forbidden | Authenticated but not allowed | Valid identity, but lacks permission (wrong role, plan, region, blocked IP) |
| 404 Not Found | Resource not disclosed | The resource doesn't exist — or you want to hide that it exists at all |
| 407 Proxy Authentication Required | Must authenticate to a proxy | An intermediary needs auth, not the origin server |
The 401/403 line is the authentication/authorization split above. The 403/404 line is subtler and matters for security: a 403 confirms the resource exists but is off-limits, while a 404 reveals nothing. If /admin/users/42 returns 403, an attacker just learned user 42 exists. For sensitive resources, returning 404 to unauthorized users — "not willing to disclose," as the 404 spec puts it — denies that signal entirely. Use 403 when clarity for legitimate users matters; use 404 when even acknowledging the resource is a leak.
407 is the odd one out: structurally identical to 401, but the challenge comes from a proxy (via Proxy-Authenticate), not the origin. You'll mostly meet it on corporate networks.
Common Causes
In practice, 403s come from a short list of repeat offenders:
- Insufficient permissions — The user is authenticated but their role, scope, or subscription tier doesn't grant access. The classic free-user-hits-premium-endpoint case.
- IP or region blocked — A firewall rule, geo-restriction, or allow-list rejects the client's address. Common with WAFs and licensing/compliance restrictions.
- Resource restricted regardless of identity — Some paths are simply not served to anyone over this channel (a private admin tool, an internal API exposed by mistake).
- Directory listing denied — A web server configured not to serve directory indexes returns 403 when you request a folder with no index file.
- Missing or failed CSRF / origin checks — Many frameworks return 403 when a state-changing request lacks a valid CSRF token or fails an origin check.
- Filesystem permissions — On a static server, the file exists but the server process can't read it (wrong
chmod/owner), which surfaces to the client as a 403.
Note what's not on this list: "forgot to log in" and "token expired." Those are 401s.
Returning 403 Correctly Across Platforms
A good 403 says enough about why — without handing an attacker a map. When you can safely explain the reason, do; when the existence of the resource is itself sensitive, prefer a 404.
Express / Node.js
function requireRole(role) {
return (req, res, next) => {
// req.user is already populated by your auth middleware — a 401 was
// handled earlier if there were no/invalid credentials.
if (!req.user.roles.includes(role)) {
return res.status(403).json({
error: "forbidden",
message: `Requires the "${role}" role.`,
});
}
next();
};
}
app.delete("/api/projects/:id", requireRole("admin"), deleteProject);The key sequence: authenticate first (401 if that fails), then authorize (403 if that fails). A 403 from a route where req.user is undefined is a bug — that should have been a 401.
Next.js App Router
In a Route Handler, return a NextResponse with the status and a structured body:
// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
export async function DELETE(request: Request) {
const session = await getSession(request);
if (!session) {
// No identity at all — this is a 401, not a 403.
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
}
if (session.role !== "admin") {
return NextResponse.json(
{ error: "forbidden", message: "Admin role required." },
{ status: 403 },
);
}
// ...perform the deletion
return NextResponse.json({ ok: true });
}For pages (not APIs), Next.js has a dedicated authorization interrupt. Enable the experimental flag:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: { authInterrupts: true },
};
export default nextConfig;Then call forbidden() from a Server Component, Server Function, or Route Handler to render your 403 UI and send a real 403 status:
// app/admin/page.tsx
import { forbidden } from "next/navigation";
import { verifySession } from "@/lib/dal";
export default async function AdminPage() {
const session = await verifySession();
if (session.role !== "admin") {
forbidden(); // renders forbidden.tsx, responds 403
}
return <AdminDashboard user={session.user} />;
}Customize the rendered page with a forbidden.tsx file convention (a sibling to not-found.tsx):
// app/forbidden.tsx
import Link from "next/link";
export default function Forbidden() {
return (
<main>
<h2>Forbidden</h2>
<p>You don't have permission to access this resource.</p>
<Link href="/">Return home</Link>
</main>
);
}Use forbidden() for the "logged in but not allowed" case, and reserve unauthorized() (which renders a 401) for the "needs to log in" case.
NGINX
NGINX returns 403 from its access-control directives, or you can send one explicitly:
location /internal/ {
allow 10.0.0.0/8; # office / VPN range
deny all; # everyone else gets 403
}
location = /admin {
# Block by hand, e.g. behind an auth_request that failed authorization
return 403;
}deny all; and a failed allow/deny match both produce a 403 automatically. A request for a directory with no index file and autoindex off; also yields 403.
Apache
<Directory "/var/www/private">
Require all denied # Apache 2.4 — returns 403 to everyone
</Directory>
<Location "/admin">
Require ip 203.0.113.0/24 # only this range is authorized; others get 403
</Location>REST API JSON body
For machine consumers, pair the status with a stable, structured body so clients can branch without scraping prose:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "forbidden",
"message": "Your plan does not include export. Upgrade to Pro to continue.",
"documentation": "https://api.example.com/docs/errors#forbidden"
}Don't return 200 OK with { "error": "forbidden" }. Any decent HTTP client treats 2xx as success, so your error handling never fires and you special-case every call site forever.
403 and SEO
Search crawlers experience a 403 as a locked door: they can't authorize themselves, so they don't get in.
- Pages returning 403 aren't indexed. Google reads it as "access denied" and excludes the URL from search results.
- 403 is a soft error to Google — less final than 404 or 410. The URL won't be indexed, but Google may re-check it later in case access rules change.
- Don't gate SEO-important content behind 403. If a page needs to rank, it has to be reachable without special permission. For paywalled or member content, serve an indexable public preview (a real
200 OKsummary) and put only the full content behind authorization. - 403-on-the-page is not the same as
robots.txt. Blocking a crawler inrobots.txttells it not to fetch the URL at all; a 403 lets it fetch and then refuses. If your intent is "don't crawl this," userobots.txtornoindex, not a 403.
Common Pitfalls
- Using 403 where 401 belongs (and vice versa). Returning 403 to a logged-out user is wrong — they could fix it by logging in, so it's a 401. Returning 401 to a logged-in user who lacks permission is also wrong — re-authenticating won't help, so it's a 403.
- Leaking existence with 403. A 403 confirms the resource is real. For sensitive objects, returning 404 to unauthorized users prevents attackers from enumerating which IDs, usernames, or paths exist. Keep the chosen code consistent across existing and non-existing resources, or the difference itself becomes the leak.
- Returning 200 with an error body. Breaks every client's success/failure branching and silently disables retry and error-reporting logic.
- Explaining too much in the body. "User 42 lacks the
billing:writescope on org 9" is helpful for your own dashboard and a gift to an attacker on a public API. Match the verbosity of the reason to how sensitive the context is. - Retrying or refreshing on a 403. A 403 means refreshing the token won't help — the identity is fine. Client code that runs its 401 refresh-and-retry path on a 403 just wastes a round trip and hides the real "you need different permissions" problem from the user. Branch on the status code.
- Confusing access control with rate limiting. "You're blocked because you sent too many requests" is 429 Too Many Requests, not a permanent 403 — the client can fix it by slowing down. Reserve 403 for "not allowed," not "not right now."
Wrapping Up
403 is small but routinely misused. The rules of thumb:
- 403 is about authorization (what you're allowed), not authentication (who you are) — that's 401
- If logging in could plausibly fix it, it's a 401; if the user is already authenticated and still can't proceed, it's a 403
- Use 404 instead of 403 when even revealing that the resource exists is a security leak — and stay consistent so the code itself doesn't leak
- On the client, never run your token refresh-and-retry path on a 403 — branch on the status
- Never return
200with an error body, and keep the body's detail proportional to how sensitive the context is
For more, see our pages on 403 Forbidden, 401 Unauthorized, and 404 Not Found. The authentication side of this story is covered in our understanding 401 Unauthorized post, the "doesn't exist vs. won't disclose" angle in understanding 404 Not Found, and you can compare 401 vs 403 side by side.