Part 9 · Security for System Designers

Authentication vs Authorization: Sessions, Cookies & JWT

Every endpoint checked “is the user logged in?” — and changing one number in the URL showed another customer's invoice. Authentication vs authorization, storing passwords, MFA and passkeys, sessions vs JWTs (with a live token to tamper with), and RBAC, ABAC and ReBAC.

Lesson 4912 min readintermediate#security#auth#jwt

On this page

  1. The Problem
  2. The Core Idea
  3. How It Works
  4. Trade-offs
  5. In the Real World
  6. Interview Questions
  7. Key Takeaways
  8. Further Reading

The Problem

A new API goes live. An API is a way for programs to talk to your server, and an endpoint is one URL of that API. Every endpoint checks "is the user logged in?". Then someone notices that changing the number in the URL, from GET /invoices/1001 to GET /invoices/1002, shows another customer's invoice. The user was logged in. They just weren't allowed to see that invoice.

Elsewhere, a team switches from server sessions to JWTs "because they scale better". A session is data the server keeps about a logged-in user. A JWT is a signed token (a small piece of text) that the user sends with each request. Then an account is hacked, and the team finds out they cannot log the attacker out. The stolen token stays valid for 30 days.

Most security problems in real systems are not clever attacks on cryptography. They come from mixing up who you are (authentication) with what you are allowed to do (authorization). They also come from choosing a session or token design without knowing its trade-offs. The difference in one line: authentication checks who you are, and authorization checks what you may do.

The Core Idea

Think about checking into a hotel.

  • Authentication (AuthN): "Who are you?" At the front desk, you show your ID, and the staff confirm you are who you claim to be.
  • The key card is the session or token. Instead of showing your ID at every door, you get a key card that proves you already checked in.
  • Authorization (AuthZ): "What are you allowed to do?" Your key card opens your room, the gym and the pool, but not other guests' rooms or the staff office.

Two important lessons from the hotel:

  1. Having a key card (being logged in) doesn't mean you can open every door. Every door must check permission.
  2. If a key card is stolen, the hotel must be able to cancel it quickly.

How It Works


Part 1: Authentication, proving identity

Passwords, stored safely.

  • Never store passwords in plain text. Do not use fast hashes like MD5 or SHA-256 either. A hash is a one-way function that turns a password into a fixed string that cannot be turned back. Attackers who steal the database can try billions of guesses per second against a fast hash.
  • Use a slow, salted password-hashing algorithm made for this: Argon2id (the first choice), scrypt or bcrypt. A salt is a random value added to each password before hashing. Because of the salt, two users with the same password get different hashes.
  • Check new passwords against lists of known breached passwords (passwords that leaked before). Prefer long passphrases over complex rules. NIST (a US standards body) recommends this in its modern guidance. It also discourages forcing users to change passwords on a schedule.
  • Protect login from brute force (trying many passwords one after another) with rate limiting. Rate limiting means allowing only a few attempts in a given time, per account and per IP address (post 43). After repeated failures, use a CAPTCHA (a test that a human can pass) or an extra check.
  • Use generic error messages such as "invalid email or password". Then attackers cannot find out which emails have accounts.

Multi-factor authentication (MFA) asks for a second proof besides the password, such as a code from your phone. A second factor stops most account takeovers, even when passwords leak. Common options:

  • TOTP apps (time-based one-time password apps, like Google Authenticator), which show 6-digit codes that change every 30 seconds,
  • SMS OTP (a one-time password sent by text message), which is better than nothing but weak against SIM-swap attacks (an attacker moves your phone number to their own SIM card),
  • push approvals, which are easy to use but weak against "MFA fatigue". This is when an attacker sends prompt after prompt until the user taps approve by mistake. Number matching (the user must type a number shown on the login screen) reduces this,
  • hardware security keys and passkeys, the strongest options.

Passkeys (WebAuthn / FIDO2) replace passwords with public-key cryptography. In public-key cryptography there are two linked keys. A private key stays secret, and a public key can be shared. Your device keeps the private key, and the website stores only the public key. To sign in, your device proves it has the private key. Your fingerprint, face or PIN unlocks it.

  • ✅ Phishing-resistant: a passkey only works on the real website's domain, so a fake site cannot use it. (Phishing means tricking users on a fake site.)
  • ✅ Nothing secret is stored on the server to steal.
  • Apple, Google and Microsoft all support passkeys, and many major sites offer them.

Single sign-on (SSO) lets users log in once and then use many apps. They log in with an identity provider, which is a service that keeps user accounts (Google, Microsoft Entra ID, Okta, or a company's own login). SSO uses OAuth 2.0 / OpenID Connect or SAML, which are standard login protocols (post 50).


Part 2: Staying logged in, sessions vs tokens

After login, the server must recognise the user on every request. HTTP is stateless (post 3), which means the server does not remember earlier requests. So there are two main approaches.

Option A: Server-side sessions

step 1 / 8
Browser
App server
Redis
  1. POST /login (email + password)
  2. SET session:k9Fq… = {user 42, roles, expires}
  3. Set-Cookie: sid=k9Fq…; HttpOnly; Secure; SameSite=Lax
  4. GET /account (cookie sid=k9Fq…)
  5. GET session:k9Fq…
  6. user 42
  7. 200 OK
  8. Logout or ban → DEL session:k9Fq… — revoked everywhere at once
Server-side sessions. Only a random ID travels; the data stays on the server, so logout is instant. · sequence · step through
  • ✅ Easy to revoke. Revoke means to cancel. Delete the session and the user is logged out instantly, everywhere you choose.
  • ✅ Small cookie. A cookie is a small piece of data the browser stores and sends back with every request. Here only a random ID travels. The data stays on the server.
  • ✅ Simple, mature and well understood.
  • ❌ A lookup on every request. You need a shared session store (like Redis) so any server can check any session (post 7). This is usually fast and cheap.
  • ❌ Harder to use across many domains or for non-browser clients.

Option B: Tokens (JWT)

A JSON Web Token (JWT) is a signed piece of data that the client sends with each request. A signature is a code made with a secret key. It proves that nobody changed the data. The server can verify the signature without looking anything up.

A JWT has three parts, header.payload.signature. Each part is Base64URL-encoded. Base64URL is a way to write data as plain letters and digits that are safe in a URL. It is not encryption:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFzaGEiLCJyb2xlIjoiZWRpdG9yIiwiaWF0IjoxNzY3MjI1NjAwLCJleHAiOjE3NjcyMjY1MDB9.3mQf9Zx1yV7bK0aLp2Rt8sWc4dNe6hJg5uOi1kPq
Header
{
 "alg": "HS256",
 "typ": "JWT"
}
Which algorithm signed it.
Payload (claims)
{
 "sub": "user_42",
 "name": "Asha",
 "role": "editor",
 "iat": 1767225600,
 "exp": 1767226500
}
Only Base64 — anyone can read it. Never put secrets here.
Signature
HMAC-SHA256(header + "." + payload, secret)
Only the server knows the secret, so only it can make a valid one.

The server recomputes the signature from header + payload with its secret. It matches → the claims are trusted, with no database lookup.

A JWT, colour-coded into its three parts. Try editing the payload and see why the server rejects it. · interactive

Standard claims. A claim is one fact stored in the payload:

  • iss (issuer): who created the token.
  • sub (subject): who the token is about (the user ID).
  • aud (audience): which service it's meant for.
  • exp (expiry), iat (issued at), nbf (not before).
  • jti: a unique token ID (useful for a list of revoked tokens).

Signing algorithms:

  • HS256 (HMAC with a shared secret). HMAC is a way to sign data with one shared secret. The same secret signs and verifies, so every service that verifies can also create tokens. Use it only inside one trusted service.
  • RS256 / ES256 (public-key signatures). The auth server signs with a private key, and every other service verifies with the public key. This is safer when you have many services. The public keys are often published at a JWKS (JSON Web Key Set) endpoint, which is a URL that lists them.

⚠️ JWTs are signed, not encrypted. Anyone holding the token can decode and read the payload. Never put secrets or sensitive personal data in a JWT. Encrypted tokens, called JWE, exist, but they are less common.

Why people like JWTs:

  • ✅ Stateless verification: there is no session lookup, so any service can check the token on its own.
  • ✅ Useful for APIs, mobile apps, and passing identity between services.
  • ✅ A standard format, widely supported by libraries and identity providers.

The big JWT problem: revocation. Servers do not look tokens up. So a valid token keeps working until it expires, even after the user logs out, changes their password, or is banned. Here are some solutions:

  • Short-lived access tokens (5–15 minutes) plus refresh tokens (below).
  • A deny list of revoked token IDs (jti), checked on each request. But this is a lookup again, just like sessions.
  • Token versioning: store a token_version number for each user. Raise the number to cancel all older tokens. This also needs a lookup, but it is small and easy to cache (keep a copy in fast memory).

Common JWT mistakes (all of them have happened in real systems):

  • accepting "alg": "none" (unsigned tokens), or letting the token choose its own algorithm. Always pin the expected algorithm on the server,
  • not checking exp, aud or iss,
  • algorithm confusion, such as a server that treats a public key as an HMAC secret,
  • long expiry times (days or weeks) with no revocation plan,
  • huge tokens stuffed with permissions, sent on every request,
  • storing tokens in localStorage, where any cross-site scripting (XSS) bug can steal them. XSS means an attacker gets their own JavaScript to run on your page.

Use a well-maintained library, and follow the JWT Best Current Practices (RFC 8725, an official internet standards document).

Access tokens + refresh tokens

A common and safer design uses two tokens. An access token is short-lived and is sent with each API request. A refresh token is long-lived and is used only to get a new access token:

step 1 / 8
Client
API
Auth server
  1. GET /orders · Bearer access (10 min)
  2. 200 OK
    verified locally, no lookup
  3. Ten minutes later the access token has expired
  4. GET /orders · Bearer access
  5. 401 token expired
  6. POST /token · refresh token R1
  7. ↻ R1 in DB and not revoked? → invalidate R1
  8. new access token + refresh token R2
    rotation
Access + refresh tokens with rotation. Stateless where it's hot, revocable where it matters. · sequence · step through
  • Access tokens are verified statelessly (fast), and a stolen one works for only minutes.
  • Refresh tokens are checked against the database, so they can be revoked. Logging out or changing the password deletes them.
  • Refresh token rotation: each use returns a new refresh token and invalidates the old one. If an old refresh token is ever used again, it was probably stolen, so revoke the whole chain of tokens.

Where to keep tokens in a browser

  • HttpOnly cookies can't be read by JavaScript, which protects them from XSS theft. This is the recommended place. An HttpOnly cookie is a cookie that the browser keeps away from JavaScript.
  • localStorage or sessionStorage is readable by any script on the page, so one XSS bug leaks every token.
  • For single-page apps, a common pattern is the Backend-for-Frontend (BFF) (post 13). A BFF is a small server that sits between your front end and the APIs. It keeps the tokens on the server and gives the browser only an HttpOnly session cookie.

Cookie security settings

HTTP
Set-Cookie: sid=k9Fq…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
  • HttpOnly: JavaScript can't read it (blocks XSS token theft).
  • Secure: it's only sent over HTTPS.
  • SameSite:
    • Strict: never sent with requests from other sites.
    • Lax: sent on top-level navigation, but not on most cross-site requests. It's a good default, and blocks most CSRF.
    • None: always sent, and must be used with Secure. It's needed for some cross-site setups.
  • Domain / Path: keep the scope as narrow as possible.
  • An expiry, plus server-side expiry as well.

CSRF (Cross-Site Request Forgery) is an attack where a bad website tricks your browser into sending a request to another site with your cookies attached, such as "transfer money". Defences:

  • SameSite cookies,
  • anti-CSRF tokens (secret values that only your own pages know) in forms and in requests that change data,
  • checking the Origin header.

Sessions or JWTs?

Server sessionsJWT access tokens
Revocation✅ Instant❌ Hard (short expiry + refresh tokens)
Lookup per requestYes (fast Redis lookup)No (verify signature)
Best forFirst-party web appsAPIs, mobile apps, service-to-service, third-party access
SizeTiny cookieBigger (claims + signature)
ComplexityLowHigher (keys, rotation, expiry, refresh)

A practical default: use server-side sessions with HttpOnly cookies for your own website. Use short-lived JWT access tokens with rotating refresh tokens for APIs, mobile apps and between services, often issued by an OAuth 2.0 / OpenID Connect provider (post 50).


Part 3: Authorization, what you may do

Authentication tells you who. Authorization decides what they can do. Common models:

RBAC (Role-Based Access Control). Users get roles (like "admin" or "viewer"), and each role has a list of permissions.

Text
roles:  admin → [read, write, delete, manage_users]        editor → [read, write]        viewer → [read]

It's simple and very common, but it gets messy when you need rules like "can edit only documents in their team".

ABAC (Attribute-Based Access Control). Decisions use attributes (facts) about the user, the resource and the situation, such as the user's department, the document's secrecy level or the time of day. A rule might say "managers can read salary data during work hours":

RBAC
  • •users → roles → permissions
  • +Simple; easy to audit
  • −Explodes into many roles for “only their team's documents”
Use whenAdmin panels, most business apps
ABAC
  • •rules over user, resource and context attributes
  • +Very flexible (department, clearance, time)
  • −Harder to understand and audit
Use whenRegulated and enterprise data
ReBAC
  • •permissions follow relationships (member → team → folder → doc)
  • +Natural for sharing models
  • −Needs a dedicated system (Zanzibar, SpiceDB, OpenFGA)
Use whenDrive, GitHub, Notion-style sharing
Three authorization models, from simplest to most expressive. · compare

ABAC is very flexible, but it is harder to understand and to audit (to check later who could do what).

ReBAC (Relationship-Based Access Control). Permissions come from relationships: "Asha is an editor of document D because she's a member of team T, which owns folder F, which contains D." It fits sharing models like Google Drive, GitHub or Notion very well. Google described its system for this, Zanzibar, in a 2019 paper. Open-source systems inspired by it include SpiceDB and OpenFGA.

ACLs (Access Control Lists). Each resource keeps its own list of who can do what. This is simple for small systems.

Where to check permissions

  • The API gateway (the front door server for all API calls) can check "is there a valid token?" and simple, broad rules (post 13).
  • Each service must check object-level permissions: "is user 42 allowed to read invoice 1002?"
  • The data layer can add a safety net. One way is row-level security in PostgreSQL, where the database itself hides rows a user may not see. Another way is to always filter queries by tenant_id (the customer account that owns the data).

The #1 API weakness is broken object-level authorization. It is also called IDOR (Insecure Direct Object Reference). The server checks that a user is logged in, but it does not check that the user owns the object (the record) they ask for. The invoice example at the start of this post is exactly this.

JavaScript
// ❌ Vulnerableapp.get("/invoices/:id", requireLogin, async (req, res) => {  res.json(await db.invoices.findById(req.params.id));});// ✅ Fixed: always scope to the current user/tenantapp.get("/invoices/:id", requireLogin, async (req, res) => {  const invoice = await db.invoices.findOne({ id: req.params.id, customerId: req.user.id });  if (!invoice) return res.status(404).end();   // 404 hides whether it exists  res.json(invoice);});

Policy engines let you write authorization rules outside your application code, so the rules stay the same everywhere and are easy to audit:

  • Open Policy Agent (OPA) with its Rego language,
  • Cedar (from AWS),
  • or Zanzibar-style services.

Service-to-service identity

Services must authenticate each other too:

  • API keys: simple, but long-lived secrets to manage (post 51).
  • OAuth client credentials: short-lived tokens (post 50).
  • mTLS (mutual TLS): both sides show a certificate (a digital ID card) to prove who they are. Service meshes (tools that manage traffic between services) can automate this (post 51).
  • Workload identity (for example, SPIFFE or cloud IAM roles): the platform gives each service an identity, so no secret is written into the code.

The principle of least privilege applies everywhere. Every user, service and key should have the fewest permissions it needs, and nothing more.

Trade-offs

  • Sessions: simple and instantly revocable, but they need a shared store.
  • JWTs: stateless and good for distributed APIs, but hard to revoke, easy to misconfigure, and larger.
  • Short token lifetimes: safer, but more refresh traffic and complexity.
  • RBAC: simple, but becomes coarse or explodes into many roles. ABAC and ReBAC: fine-grained, but more complex, and they need tooling.
  • Central policy engines: consistent and auditable, but add another dependency and a little latency.
  • MFA and passkeys: much better security, at the cost of some user friction and recovery flows to design (lost phones!).

In the Real World

Facebook's plain-text password logs (2019). Facebook said it had found hundreds of millions of user passwords stored in plain text in internal logs. Employees could read them. Facebook said it had no evidence of misuse. It's a reminder that "never store passwords in plain text" includes logs (post 46).

Passkeys go mainstream. Since 2022, Apple, Google and Microsoft have supported passkeys across their platforms, and services like Google accounts, GitHub, PayPal and Amazon offer them. It's one of the biggest shifts in authentication in decades.

Google Zanzibar. Google's Zanzibar handles authorization checks for products like Drive, YouTube and Photos, answering "can this user access this object?" consistently across the globe at very high volume. Its paper inspired a wave of open-source ReBAC systems.

JWT "alg: none" weaknesses. In 2015, security researchers showed that several popular JWT libraries could be tricked into accepting unsigned tokens, or into mixing up algorithms. Libraries were fixed, and "always pin the algorithm" became standard advice.

Broken object-level authorization in the news. Many real data leaks, from telecom companies to fitness apps, came from APIs that returned any record by ID without checking ownership. That's why it sits at the top of the OWASP API Security Top 10 (post 52).

Interview Questions

Q1What's the difference between authentication and authorization?

Authentication establishes who the user is (password, passkey, MFA, SSO). Authorization decides what that identity may do with a specific resource. Being logged in never implies access to every object.

Q2How should passwords be stored?

Never in plain text or with fast hashes like MD5 or SHA-256. Use a slow, salted password hash — Argon2id first, or scrypt or bcrypt — check new passwords against breached lists, rate-limit logins and add MFA.

Q3Sessions or JWTs?

Server-side sessions in HttpOnly, Secure, SameSite cookies suit first-party web apps: small, simple and instantly revocable, at the cost of a fast lookup. JWT access tokens suit APIs, mobile apps and service-to-service calls because they verify locally, but they're hard to revoke — so keep them short-lived with rotating refresh tokens.

Q4What are common JWT mistakes?

Accepting alg: none or letting the token pick its algorithm, not validating exp, aud and iss, algorithm confusion between HMAC and public keys, long expiries with no revocation plan, putting sensitive data in the readable payload, and storing tokens in localStorage.

Q5What is IDOR / broken object-level authorization, and how do you prevent it?

Returning an object by ID after checking only that the user is logged in, so changing the ID exposes someone else's data. Scope every query to the current user or tenant (WHERE id = ? AND customer_id = ?), return 404 when not found, and back it up with row-level security.

Q6How does refresh-token rotation detect theft?

Each refresh returns a new refresh token and invalidates the old one. If an already-used token is presented again, two parties hold it — so the server revokes the whole token family and forces re-login.

Key Takeaways

  • Authentication = who you are. Authorization = what you can do. Being logged in doesn't grant access to every object.
  • Store passwords with Argon2id, scrypt or bcrypt, rate-limit logins, and add MFA. Passkeys are the strongest, phishing-resistant option.
  • Server sessions in HttpOnly; Secure; SameSite cookies are simple and instantly revocable, the best default for first-party web apps.
  • JWTs are signed, not encrypted. Pin the algorithm, validate exp/aud/iss, keep them short-lived, and use rotating refresh tokens for revocation. Don't store them in localStorage.
  • Check object-level authorization on every request (to prevent IDOR). Choose RBAC, ABAC or ReBAC to fit your sharing model, and apply least privilege to users and services.

Further Reading

  • OWASP Cheat Sheet Series: Authentication, Password Storage, Session Management, Authorization, CSRF Prevention, and JSON Web Token cheat sheets
  • NIST Special Publication 800-63B (Digital Identity Guidelines: Authentication)
  • RFC 7519 (JSON Web Token) and RFC 8725 (JWT Best Current Practices)
  • RFC 6265 (HTTP cookies) and MDN's guide to using HTTP cookies
  • The Google paper "Zanzibar: Google's Consistent, Global Authorization System" (USENIX ATC 2019)
  • The W3C WebAuthn specification and the passkeys.dev developer resource
  • The Open Policy Agent documentation

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series