Authentication vs. Authorization — Identity, Sessions, Tokens, and Access Control
Clearly separate 'who are you' (authentication) from 'what can you do' (authorization); understand identity as the durable fact a system trusts about a user; compare server-side sessions against bearer tokens as ways of carrying that identity across requests; and see how access control decisions are made once identity is established.
Every request that touches anything private has to answer two completely different questions, in a fixed order: first, who is making this request -- authentication -- and only after that is settled, is this specific person allowed to do this specific thing -- authorization. It is easy to blur the two because they sit right next to each other in almost every login flow, but conflating them produces real bugs: a system that only checks 'are you logged in' and never checks 'are you allowed to edit THIS order' will happily let any logged-in user edit anyone else's data. This lesson builds the vocabulary -- identity, sessions, tokens, access control -- that the rest of this section's lessons (password hashing, secure login, password reset) all assume.
Authentication: Establishing Identity
Authentication is the process of a system convincing itself that a request really comes from the person (or service) it claims to come from. In a typical web app this happens once, explicitly, at login: the user supplies a credential -- a password, a one-time code, a biometric signal, a client certificate -- and the server checks it against what it has on file. If the check passes, the server now has an authenticated 'identity' for that request: a stable, unique reference to a specific account, almost always a primary-key user ID rather than something mutable like an email address or display name (email and name can both change; the ID that identity is pinned to should not).
Authentication answers exactly one question -- who is this -- and nothing more. A successfully authenticated request tells you nothing yet about what that identity is permitted to do; that is a separate, later decision. Conflating 'I know who you are' with 'therefore you may do X' is the single most common security design mistake in web applications, because it silently assumes every authenticated user is equally privileged, which is essentially never true once an app has more than one type of user.
• Authentication = proving identity: 'who are you'. • Happens once (login), producing an identity the rest of the request/session can rely on. • Identity should be pinned to a stable, immutable reference (a user ID), never a mutable field like email or username. • A successful authentication says nothing about permissions -- that's a separate concern, checked separately, every time.
Authorization: Deciding What an Identity Can Do
Authorization is the process of deciding, given an already-established identity, whether that identity is allowed to perform a specific action on a specific resource -- view this order, delete this comment, access this admin panel. Unlike authentication, which typically happens once at login, authorization has to be re-evaluated on every single request that touches something protected, because the question 'can user 42 delete order 99' depends on both WHO is asking (identity, already known) and WHAT they're asking to do (which changes on every request).
The simplest form of authorization is a role check (is this user an 'admin'), but production systems usually need finer granularity: ownership checks (does this order belong to this user), resource-scoped roles (an 'editor' on THIS document, not globally), and permission systems that compose several of these. What all of them share is that they run AFTER authentication has already established who is asking, and they must be checked server-side on every protected action -- a 'Delete' button simply not being rendered in the UI for a non-owner is not authorization, it's a suggestion an attacker can ignore by calling the endpoint directly.
• Authorization = deciding permissions: 'what can you do, on this specific thing'. • Re-checked on every protected request -- never assumed to carry over from having passed authentication once. • Ranges from simple role checks ('is admin') to per-resource ownership and scoped permission checks. • Must be enforced server-side; hiding a button in the UI is not access control, it's cosmetic and trivially bypassed.
Carrying Identity Across Requests: Sessions and Tokens
HTTP is stateless -- a server that authenticates a user on one request has no memory of that by the next request unless something carries the identity forward. Two dominant mechanisms do this. A session is server-side state: after login, the server generates a random, unguessable session ID, stores the associated user ID (and other session data) in server-side storage (files, a database, Redis), and sends only that opaque ID to the browser in a cookie. Every later request, the browser automatically resends the cookie, and the server looks up the session ID to recover who the request is from. The client never sees or controls anything meaningful -- the session ID by itself carries no information, so it must be looked up server-side to mean anything.
A token, most commonly a JWT (JSON Web Token) or a simple opaque API key, instead carries identity (and sometimes claims about permissions) directly inside the value itself, cryptographically signed so the server can verify it wasn't tampered with, without necessarily needing a server-side lookup per request. Tokens are the natural fit for APIs and mobile/SPA clients calling a backend that doesn't want to hold server-side session state per request; sessions remain the simpler, more revocable default for traditional server-rendered web apps where a cookie-based flow is natural.
The key practical difference is revocation: a server-side session can be instantly invalidated by deleting its record from storage, while a signed token remains valid until it expires unless the server maintains an extra revocation list (a blocklist of token IDs) to check against -- which reintroduces some of the server-side state tokens were meant to avoid. Neither mechanism is universally 'better'; the choice depends on whether the server needs instant revocation and whether it's serving a traditional cookie-based web app or a decoupled API client.
• Session: server holds the real state; client holds only an opaque, meaningless ID in a cookie. Easy to revoke (delete the server record). • Token (e.g. JWT): the value itself carries signed identity/claims; the server can verify it without a lookup, but revocation before expiry needs an extra blocklist. • Sessions suit traditional cookie-based server-rendered apps; tokens suit APIs and clients calling a stateless backend. • Whichever mechanism carries identity, the same authentication-then-authorization order still applies on every request.
Code Examples
currentUserId() answers authentication (reading the identity the session already established); canEditOrder() answers authorization (checking that specific identity against that specific order's ownership). The two are separate functions, checked in sequence, with distinct HTTP status codes (401 vs 403) for their distinct failure modes. This ownership check runs on every request to this endpoint -- there is no shortcut where 'the user was already authenticated earlier' is treated as proof they may edit this particular order.
if ($order['customer_id'] === $userId) { Common mistake: Checking only if ($userId !== null) and skipping the ownership/role check entirely, which lets any logged-in user edit any other user's order simply because they are logged in as SOMEONE.
<?php
declare(strict_types=1);
session_start();
// --- AUTHENTICATION: who is making this request? ---
function currentUserId(): ?int
{
// The session already proved identity at login time (see the secure-login lesson);
// here we just read the identity it established.
return $_SESSION['user_id'] ?? null;
}
// --- AUTHORIZATION: is this identity allowed to do THIS action, on THIS resource? ---
function canEditOrder(int $userId, array $order): bool
{
// Ownership check: only the order's own customer, or an admin, may edit it.
if ($order['customer_id'] === $userId) {
return true;
}
return isAdmin($userId);
}
function isAdmin(int $userId): bool
{
static $admins = [1, 2]; // in real code, looked up from the users table/role table
return in_array($userId, $admins, true);
}
// --- Usage: an endpoint handling PATCH /orders/{id} ---
function handleEditOrder(array $order): void
{
$userId = currentUserId();
if ($userId === null) {
http_response_code(401); // authentication failed: we don't know who this is
echo json_encode(['error' => 'Not authenticated']);
return;
}
if (!canEditOrder($userId, $order))
…Authentication then authorization, on every protected request
401 means authentication failed (we don't know who you are); 403 means authentication succeeded but authorization denied the specific action.
Request arrives
|
v
[ AUTHENTICATION ] "who is this?"
cookie/session ID -> look up session -> user id
OR bearer token -> verify signature -> user id
|
| identity established (or request rejected: 401)
v
[ AUTHORIZATION ] "is THIS user allowed to do THIS thing?"
role check / ownership check / permission check
|
| allowed (or request rejected: 403)
v
handler runsImportant Rules
Always ask 'who is this' (authentication) before 'what can they do' (authorization) -- and never skip the second question just because the first one passed.
Pin identity to a stable, immutable ID (primary key), never to a mutable field like email or display name.
Re-check authorization on every protected request; never assume it carries over from a past request or from having passed authentication once.
Enforce authorization server-side, on every handler -- hiding UI elements is cosmetic, not access control.
Return 401 when identity is unknown/invalid, and 403 when identity is known but the specific action is denied -- the two failure modes are not the same thing.
Why 'logged in' and 'allowed' are different lines of code everywhere
Every mature framework keeps these concerns as separate middleware layers: an 'auth' middleware that resolves the current identity (or rejects with 401), and a distinct 'authorize'/'policy'/'gate' layer that checks that identity against the specific resource and action (or rejects with 403). Laravel's Gates and Policies, Symfony's Voters, and most API frameworks' permission systems all exist specifically so authorization logic lives in one reusable, testable place instead of being re-implemented ad hoc inside every controller.
Treating 'the user is logged in' as sufficient reason to let them perform any action, with no per-resource ownership or role check.
Check authentication first, then run a separate, explicit authorization check (ownership, role, or permission) for the specific action and resource before proceeding.
Being logged in only proves identity; it says nothing about what that identity is entitled to do to a specific piece of data. Skipping the second check is how one user ends up able to read or modify another user's records.
Identifying the current user by a mutable field like their email address stored in the session, and re-deriving permissions from it on each request.
Store and check the immutable primary-key user ID as the identity, looking up current role/permissions fresh from that ID each time.
A mutable field can change (a user updates their email) or even be reused by a different account over time in some systems, silently detaching the session from the account it was meant to represent.
A logged-in user successfully sends a request to delete a blog post they did not write, and the server deletes it without any further check. What went wrong? A. Authentication failed B. Authorization was skipped -- being authenticated was wrongly treated as sufficient permission C. The session expired too early D. The password was stored in plaintext
B. Authorization was skipped -- being authenticated was wrongly treated as sufficient permission — The user's identity was correctly established (authentication succeeded), but no separate check confirmed they were allowed to delete THIS specific post -- that missing authorization check is the bug.
What is the key practical difference between a server-side session and a signed bearer token (like a JWT) for carrying identity? A. Sessions cannot be used over HTTPS B. Tokens can never carry any user information C. A session can be revoked instantly by deleting its server-side record, while a signed token remains valid until it expires unless a separate revocation list is checked D. There is no meaningful difference; they are interchangeable in every scenario
C. A session can be revoked instantly by deleting its server-side record, while a signed token remains valid until it expires unless a separate revocation list is checked — Because a session's meaningful state lives entirely server-side, deleting that record immediately invalidates it. A self-contained signed token is valid on its own until expiry unless the server does extra work to track revoked tokens.
Write a function canDeleteComment(int $userId, array $comment): bool that returns true only if the user is the comment's author OR holds a moderator role, following the same pattern as canEditOrder() above. Then write the two-step handler code (401 check, then 403 check) that uses it for a DELETE /comments/{id} endpoint.
function canDeleteComment(int $userId, array $comment): bool { if ($comment['author_id'] === $userId) return true; return isModerator($userId); } Then: $userId = currentUserId(); if ($userId === null) { http_response_code(401); return; } if (!canDeleteComment($userId, $comment)) { http_response_code(403); return; } // proceed with delete
Mirror the ownership-or-admin shape of canEditOrder(): compare $comment['author_id'] to $userId, then fall back to a role check.