Authentication vs Authorization - and How to Implement Both
Authentication and authorization are different things, implemented differently, with different failure modes. They’re confused so often that many codebases blur them together - and that blurring is where security bugs live.
Authentication answers: who are you? It’s the process of verifying identity. When you log in with a username and password, the system is authenticating you.
Authorization answers: what are you allowed to do? Given that the system knows who you are, it decides what actions and resources you have access to.
The distinction matters because they can fail independently. A system can correctly identify you and then give you access to the wrong things. It can deny access to the right person because identity verification is broken. Mixing the concepts makes both harder to reason about.
Authentication: sessions vs tokens
The two dominant patterns for maintaining authentication state are session-based and token-based.
Session-based authentication
The server stores session state. After login, a session ID is issued to the client as a cookie. On every subsequent request, the browser sends the cookie, the server looks up the session in its store (database, Redis, memory), and retrieves the associated user.
Login request → server validates credentials → creates session record → sends Set-Cookie: sessionId=abc123
Subsequent request → browser sends Cookie: sessionId=abc123 → server looks up session → finds user → proceeds
Advantages: sessions can be invalidated instantly - delete the session record and the user is logged out on the next request. The client never holds sensitive data; it only holds an opaque ID.
Disadvantages: the server must maintain session state. This works fine with one server but requires a shared session store (Redis is common) when you have multiple servers. If the session store goes down, all authenticated users are effectively logged out.
Token-based authentication (JWT)
A JSON Web Token (JWT) is a signed token that contains claims - typically a user ID and expiry time. After login, the server issues a JWT and sends it to the client. The client sends it on every request (usually in the Authorization: Bearer <token> header). The server validates the signature and reads the claims - no database lookup required.
Login request → server validates credentials → creates JWT, signs it → returns JWT to client
Subsequent request → client sends Authorization: Bearer eyJhb... → server validates signature → reads claims → proceeds
Structure of a JWT: three Base64URL-encoded sections separated by dots - header, payload, signature. The header specifies the signing algorithm. The payload contains the claims. The signature is the header and payload signed with a secret (HMAC) or a private key (RSA/ECDSA). Anyone can decode the header and payload - they’re not encrypted, only signed. Never put sensitive data in a JWT payload.
Advantages: stateless - no server-side storage needed. Works naturally with multiple servers and across services (with a shared secret or public key).
Disadvantages: JWTs cannot be revoked before expiry. If a token is compromised or a user is banned, you cannot invalidate it without either keeping a blocklist (defeating the stateless advantage) or waiting for the token to expire. Short expiry times (15–60 minutes) with refresh tokens are the standard mitigation.
Refresh tokens are long-lived tokens stored in the server’s database and used only to issue new short-lived access tokens. They can be revoked. When the access token expires, the client uses the refresh token to get a new one. This gives you the stateless JWT benefits for most requests with the ability to revoke sessions.
Which to use
For a traditional web application where sessions work fine and you need instant logout capability: session-based. For a distributed system, API-first backend, or mobile app where statelessness is valuable: JWTs with refresh tokens. Many systems use both - sessions for the web frontend, JWTs for the API.
Authorization: controlling access
Authentication tells you who the user is. Authorization determines what they can do with that identity.
Role-Based Access Control (RBAC)
The most common model. Users are assigned roles, roles are assigned permissions.
type Role = 'admin' | 'editor' | 'viewer';
const permissions: Record<Role, Set<string>> = {
admin: new Set(['read', 'write', 'delete', 'manage_users']),
editor: new Set(['read', 'write']),
viewer: new Set(['read']),
};
function can(user: User, action: string): boolean {
return permissions[user.role]?.has(action) ?? false;
}
// Usage
if (!can(user, 'delete')) {
throw new ForbiddenError('You do not have permission to delete this resource');
}
RBAC is easy to reason about and easy to audit. “What can an editor do?” is a single lookup. Its limitation is coarseness: it’s per-role, not per-resource. An editor can edit all posts, not just their own.
Resource-level authorization
For “users can only modify their own resources,” you need to check ownership at query time:
async function updatePost(userId: string, postId: string, data: PostData) {
const post = await db.findPost(postId);
if (!post) throw new NotFoundError();
if (post.authorId !== userId) throw new ForbiddenError();
return db.updatePost(postId, data);
}
This check must be at the data layer, not just the API layer. It’s easy to add a new API endpoint and forget the authorization check. Making authorization part of the query - WHERE id = ? AND author_id = ? - makes it impossible to skip.
Attribute-Based Access Control (ABAC)
ABAC evaluates policies against attributes of the user, the resource, and the environment. More expressive than RBAC but more complex.
type Context = { user: User; resource: Resource; action: string; environment: Environment };
function evaluate(policy: Policy, ctx: Context): boolean {
// e.g.: "editors can update posts they own, during business hours"
if (policy.role === ctx.user.role &&
policy.action === ctx.action &&
ctx.resource.ownerId === ctx.user.id) {
return true;
}
return false;
}
ABAC is worth considering when RBAC’s role explosion becomes a problem (100 roles, each a slight variant of another) or when access decisions need contextual information beyond identity.
Common mistakes
Checking authentication but not authorization. Any authenticated user can access any resource. This is the most common authorization bug in APIs. Every endpoint that returns or modifies data must verify that the current user is allowed to access that specific data.
Returning 403 when 401 is correct, or vice versa. 401 Unauthorized means unauthenticated - send credentials. 403 Forbidden means authenticated but not permitted - different credentials won’t help.
Storing passwords with reversible encryption. Passwords must be hashed with a slow hashing function: bcrypt, scrypt, or Argon2. MD5 and SHA-256 are too fast - an attacker with your database can test billions of passwords per second. A good bcrypt hash at cost factor 12 takes ~300ms; attacking a dump with bcrypt is orders of magnitude harder.
Trusting client-provided user IDs. The user ID used for authorization must come from the verified session or token, never from a query parameter or request body the client controls. A request body { "userId": "admin-id" } is attacker-controlled data.
Insufficient token expiry. Long-lived tokens are long-lived risks. A 30-day access token that’s compromised is a 30-day window of exposure. Short-lived access tokens (15–60 minutes) with refresh tokens provide a better balance.
Authentication and authorization are not one problem. They have separate implementations, separate failure modes, and separate security properties. Build them as separate concerns, and both become easier to reason about and harder to get wrong.