DEV Community

Cover image for Authentication vs Authorization: The Two Most-Confused Words in Security
Sri Balaji
Sri Balaji

Posted on Originally published at thesimplifiedtech.com

Authentication vs Authorization: The Two Most-Confused Words in Security

TL;DR: Authentication proves who you are; authorization decides what you can do. Run them in that order, carry identity with sessions or tokens, and put each check in the right place. Confuse the two and you ship security holes.

Contents

Same prefix, completely different jobs

You add a login page, users sign in, and you breathe out. The app feels secure. Then a junior engineer signs in with their own valid account, changes the URL from /orders/42 to /orders/43, and sees someone else's invoice. Nothing was "hacked", every request had a valid session. The login worked perfectly. The problem was never login at all.

That gap is the difference between authentication and authorization. They share a prefix, they sit next to each other in the request, and they get used interchangeably in standups, which is exactly why people ship the bug above. Once you can keep them apart in your head, a huge class of security mistakes simply stops happening.

📌 Who this is for: Developers who can build a login form but get fuzzy on what happens after sign-in. If you have ever wondered "is this a 401 or a 403?", written an if (user.isAdmin) check in a React component, or copy-pasted JWT verification without quite knowing what it proves, this is for you. No prior security background needed.

Two questions, in order

Authentication (authn) answers "who are you?", it proves identity. Authorization (authz) answers "what are you allowed to do?", it grants or denies access to a specific action or resource. Authn always runs first; authz depends on its answer.

Notice the ordering. You cannot decide what someone is allowed to do until you know who they are. But knowing who they are tells you nothing, on its own, about what they may do. Those are two separate decisions, made at two separate moments, often by two separate parts of your system.

In the real world In tech
Showing your passport at the security checkpoint Authentication, the system confirms you are who you claim to be
Your boarding pass says seat 14C on flight BA42 today Authorization, you may board THIS flight, in THIS seat, right now
A valid passport does not let you board any plane you want Being authenticated does not mean you are authorized for an action
The lounge agent re-checks your pass at the gate Authorization is re-checked at each protected resource, not just once

The airport makes the split obvious, two checkpoints, two different questions.

Your passport is real whether you are flying to Tokyo or standing in a parking lot, identity is stable. The boarding pass is scoped: one flight, one seat, one day. That scoping is the whole point of authorization, and it is why "they logged in" is never a complete answer to "should they be able to do this?".

Where each check lives in a request

Every protected request runs the same gauntlet. The client presents a credential, the server verifies it to establish identity, then a separate step asks whether that identity may perform this exact action on this exact resource. Only if both pass does your business logic ever run.

One request, two gates: authenticate (who), then authorize (allowed for this action?), then the handler runs.

One request, two gates: authenticate (who), then authorize (allowed for this action?), then the handler runs.

  1. Client sends a credential: A request arrives carrying proof of identity, a session cookie, a bearer JWT in the Authorization header, or an API key. No credential at all means the request is anonymous.
  2. Authenticate: who are you?: The server validates the credential, verifies the JWT signature and expiry, or looks up the session ID in its store. Success yields a trusted identity (a user id, maybe roles/scopes). Failure returns 401 Unauthorized and stops here.
  3. Authorize: allowed for THIS?: Now that identity is known, a policy check asks whether this user may perform this specific action on this specific resource, not "are they logged in" but "may user 42 delete order 43?". Failure returns 403 Forbidden.
  4. Handler runs: Only after both gates pass does the actual business logic execute. By the time your handler runs, identity is proven and the action is permitted, the handler should not have to re-litigate either.

💡 Tip: The two failure codes map cleanly: 401 Unauthorized means "I don't know who you are" (authn failed, go log in). 403 Forbidden means "I know exactly who you are, and you still can't do this" (authz failed, logging in again won't help). Despite its name, 401 is the authentication error.

Sessions vs tokens: how identity is carried

Authentication does not stop after login, proof of identity has to ride along on every subsequent request, because HTTP is stateless. There are two dominant ways to carry it, and they make opposite trade-offs.

Sessions are server-side. After login the server creates a session record (in memory, Redis, or a database) and hands the client an opaque session ID in a cookie. Every request sends the cookie; the server looks the ID up to recover the identity. The token itself carries no information, it is just a key into server state. Revoking access is trivial: delete the row.

Tokens (typically JWTs) are self-contained. After login the server signs a token that embeds the claims, user id, roles, expiry, and the client sends it on each request. The server verifies the signature and trusts the contents without a lookup, which scales beautifully across many services. The catch: a signed token is valid until it expires, so you cannot easily revoke one mid-flight. That is why access tokens should be short-lived and paired with a refresh token.

Dimension Server sessions JWT / tokens
Where state lives Server (store / DB) Inside the token itself
Lookup per request Yes, fetch the session No, verify the signature
Revocation Instant (delete the record) Hard until expiry; needs a denylist
Scales across services Needs shared session store Naturally, any service can verify
Best lifetime Long-ish, revocable Short access + refresh token
Carried in Cookie (HttpOnly) Authorization: Bearer header

Sessions vs tokens, the trade-off is statefulness vs revocability.

OAuth and OIDC, without the jargon

Two more terms get tangled into the authn/authz knot, so let us untangle them in one breath. OAuth 2.0 is an authorization framework, it lets a user grant one app limited access to their data in another app without sharing a password. When you click "Connect your Google Calendar", OAuth is what issues an access token scoped to just your calendar.

OIDC (OpenID Connect) is an authentication layer built on top of OAuth. It adds an id_token, a JWT that proves who the user is, so apps can use "Sign in with Google" for login, not just data access. Rule of thumb: OAuth = delegated authorization (access to resources); OIDC = federated authentication (proof of identity). Most "Sign in with X" buttons are OIDC; most "Allow X to access your Y" consent screens are OAuth scopes.

The scopes you see on those consent screens (read:calendar, email, profile) are authorization data riding inside an authentication flow. Your app should treat scopes as one input to its own policy decisions, not as the final word on what a user may do inside your system.

In code: verify identity, then check permission

Here is the whole idea in one Python handler. The first block proves identity from a JWT (authentication). The second block, a completely separate decision, asks whether that proven identity may act on this particular resource (authorization). Keep them visibly distinct.

orders_api.py

import jwt  # PyJWT
from fastapi import HTTPException, Request

JWT_SECRET = "...load-from-env..."

# --- AUTHENTICATION: who are you? ---
def authenticate(request: Request) -> dict:
    header = request.headers.get("Authorization", "")
    if not header.startswith("Bearer "):
        # No credential at all -> we don't know who you are.
        raise HTTPException(status_code=401, detail="Missing token")
    token = header.removeprefix("Bearer ")
    try:
        # Verifies signature AND expiry. Returns the claims if valid.
        claims = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
    return claims  # e.g. {"sub": "42", "roles": ["customer"]}


# --- AUTHORIZATION: are you allowed to do THIS? ---
def authorize_order_access(user: dict, order) -> None:
    is_owner = order.customer_id == user["sub"]
    is_admin = "admin" in user.get("roles", [])
    if not (is_owner or is_admin):
        # We know exactly who you are -- you still can't have this.
        raise HTTPException(status_code=403, detail="Forbidden")


async def get_order(request: Request, order_id: str):
    user = authenticate(request)              # 1. prove identity
    order = await db.orders.find(order_id)
    if order is None:
        raise HTTPException(status_code=404)
    authorize_order_access(user, order)        # 2. check permission
    return order                               # 3. only now: business logic
Enter fullscreen mode Exit fullscreen mode

The authorization check is resource-scoped: it compares the resource's owner against the caller. That is what stops the /orders/43 attack from the intro, a valid token authenticates the attacker, but they are not the owner, so authorize_order_access returns 403. The bug at the top of this article is exactly an app that ran step 1 and skipped step 2.

This pattern of "is the caller the owner, or do they have a role that overrides it?" is RBAC (role-based access control). When rules get richer, "managers can refund orders under €500 in their own region", you graduate to ABAC (attribute-based access control), where the decision is a function of attributes of the user, the resource, and the context. Same place in the request; just a more expressive policy.

Common mistakes that ship security holes

  1. Assuming authentication implies authorization. "They're logged in" is not "they may do this." Every protected action needs its own permission check against the specific resource, the single most common real-world breach (broken object-level authorization, the /orders/43 bug).
  2. Enforcing authorization only in the UI. Hiding the Delete button for non-admins is UX, not security. The endpoint is still reachable with curl. Every check that hides a button in the frontend must be re-enforced on the server, where it cannot be bypassed.
  3. Long-lived or non-revocable tokens. A JWT with a 30-day expiry and no denylist means a leaked token is a 30-day master key. Keep access tokens short (minutes), use refresh tokens, and have a revocation path for the cases that matter.
  4. Trusting claims you didn't verify. Decoding a JWT is not verifying it. Always check the signature and expiry (and issuer/audience) before trusting a single claim, an unverified "roles": ["admin"] is just attacker-supplied JSON.
  5. Checking permissions far from the resource. Authorization decided at the gateway with stale role data can drift from reality. Decide as close to the protected resource as you can, with current data.

Takeaways

The whole article in seven lines

  • Authentication = who are you. Authorization = what may you do. Authn runs first; authz depends on it.
  • 401 = I don't know you (authn failed). 403 = I know you and you still can't (authz failed).
  • Sessions store state on the server (easy revoke); tokens carry state in the JWT (easy scale, hard revoke).
  • OAuth = delegated authorization (resource access). OIDC = authentication on top of OAuth (proof of identity).
  • Authorization must be resource-scoped: not "are you logged in" but "may YOU touch THIS".
  • Always enforce authorization on the server, UI hiding is not security.
  • Short-lived tokens, verify signatures, decide permissions close to the resource.

Where to go next

Authn and authz are the spine of every secure system, but they live inside a bigger picture. Two natural next steps: see how these checks fit into a hardened API surface, and how identity stops being a one-time gate and becomes a continuous decision.


Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.

Top comments (0)