This article is published in English.
Authentication vs Authorization: Where Each One Belongs in Your Code
Learn how authentication and authorization differ in implementation, HTTP status codes, and system architecture to prevent common security bugs.
Two words, almost interchangeable when you skim them, yet each one governs a completely different part of your system's logic.
If you've ever sent back a 401 in a situation that actually called for a 403, or shipped a login flow and declared the authentication work finished without ever touching permission logic, you've already run into the friction that comes from mixing up these two ideas. Let's untangle them for good.
Authentication: Verifying Identity
Authentication is the step where the system confirms that a user really is who they say they are. It answers a single question: does this identity check out?
It's a one-time event, usually happening at login, and it results in some kind of credential — a session, a token, a cookie — that lets the rest of the application trust the caller without re-validating a password on every single request.
Typical ways to implement authentication include:
- Password-based auth — submitted credentials get compared against a hashed value in storage
- Token-based auth (JWT) — a signed token generated after a successful login, then checked on every subsequent call
- OAuth 2.0 / OpenID Connect — identity verification is handed off to an external provider such as Google or GitHub
- API keys — a fixed secret string that identifies a particular client or service
- Multi-factor authentication (MFA) — an extra verification step (a one-time code, an authenticator app) layered on top of the password
Here's what a standard authentication exchange looks like:
POST /api/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "hashed_and_verified_serverside"
}
Response: 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
The server validates the submitted credentials and, once they check out, hands back a token. From that point forward, this token acts as the caller's proof of identity on every request:
GET /api/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The middleware responsible for validating that token generally looks something like this:
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token provided' });
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Invalid or expired token' });
req.user = decoded;
next();
});
}
Pay attention to what this middleware deliberately leaves out. At no point does it decide what the user is permitted to do. It simply confirms the token is legitimate and attaches the decoded payload to the request object. Nothing more falls under authentication's job description.
Authorization: Verifying Permission
Authorization kicks in only after authentication has already succeeded. It answers a completely separate question: is this verified identity allowed to carry out this particular action?
Some of the common approaches for structuring authorization:
- Role-Based Access Control (RBAC) — permissions are attached to roles such as
admin,editor, orviewer - Attribute-Based Access Control (ABAC) — access decisions are computed from attributes like department, region, resource ownership, or even time of day
- Access Control Lists (ACLs) — permissions are defined explicitly per user and per resource
- Scopes (OAuth) — the token itself encodes which specific operations are allowed, like
read:messagesorwrite:files
A typical authorization check sits on top of a request that has already passed authentication:
function authorize(requiredRole) {
return (req, res, next) => {
if (req.user.role !== requiredRole) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
res.json({ data: 'sensitive admin data' });
});
These are two distinct middleware functions handling two distinct concerns, executed in a fixed order: authenticate establishes who the caller is, and authorize decides whether that caller is permitted to proceed. A request can sail through the first check and still be rejected by the second — and rejection there has nothing to do with identity.
The Status Codes That Actually Encode This Difference
This is where the theoretical distinction turns into something you can literally verify in code.
401 Unauthorized — the name is misleading, but this code actually signals an authentication problem. It means the server got no credentials at all, or the ones it received (including a token) are invalid or expired. At this point, the server has no confirmed idea who is making the request.
403 Forbidden — this one signals an authorization problem. The server has already established who the caller is; it simply won't let that identity carry out the requested action or reach the requested resource.
// Authentication failure — identity unknown or invalid
res.status(401).json({ error: 'Invalid credentials' });
// Authorization failure — identity known, permission denied
res.status(403).json({ error: 'You do not have access to this resource' });
Sending back a 401 for what is really a permissions issue is a mistake that shows up constantly. It tells the client to log in again, when the real problem is that the login is perfectly fine — the account simply lacks the required role. That one mix-up alone can turn what should be a quick fix to a permission rule into a long, misguided debugging session on the client side chasing a login bug that never existed.
Where This Shows Up In Real Architecture
JWT payloads frequently mix these concerns without anyone intending it. A token is meant to hold identity data — things like sub, email, iat, and exp. Whether it should also carry permission data, such as role or scope, is a design decision that depends on the system. Putting roles straight into the token is convenient and avoids extra lookups, but it comes with a real cost: any change to a user's permissions won't apply until that token is reissued. It's a genuine tradeoff, not a shortcut with no downside.
{
"sub": "user_12345",
"email": "user@example.com",
"role": "editor",
"iat": 1710000000,
"exp": 1710003600
}
Systems built around server-side sessions tend to keep the two concerns further apart. The session identifier handles authentication, while permissions get pulled fresh from a database on every request. This sidesteps the staleness issue described above, at the price of an extra database call per request.
In microservice setups, authentication is usually centralized — handled once by a gateway or an identity provider such as Auth0, Keycloak, or AWS Cognito, which issues a token. Authorization, however, is typically left to each individual service, which reads the claims in that token and enforces its own rules. The result is that a single authentication event can be evaluated differently by ten separate services, each deciding independently what that identity is allowed to do.
The Rule That Prevents Most Security Bugs
Authentication has to execute before authorization, and the two should never be collapsed into one combined check.
// Correct: sequential, separate concerns
app.post('/api/posts/:id/delete', authenticate, authorize('editor'), deleteHandler);
// Wrong: conflating identity with permission in one step
app.post('/api/posts/:id/delete', (req, res) => {
if (req.headers.authorization === 'valid-token-and-also-admin') {
// fragile, unscalable, and impossible to audit correctly
}
});
A system that authenticates rigorously but skips authorization confirms identity flawlessly, then allows every confirmed identity to do whatever it wants — so a single low-privilege account being compromised is effectively a total compromise. Flip it around, and a system with finely tuned authorization sitting on top of weak authentication has the opposite problem: carefully scoped permissions guarding a door that anyone can walk through unchallenged.
Both layers need to be solid, applied in the right order, and treated as separate concerns. That is the whole distinction — and once it's reflected properly in your status codes, your middleware chain, and how you design your tokens, a whole class of security bugs simply disappears.