⚡ TL;DR: Verifying a password is step one. The real decision is server sessions vs JWTs, and the part most people botch is logout and revocation. Get cookies, hashing, and token revocation right or pay in hours debugged or a breach.
Contents
- You can verify a password. Now what?
- The mental model: a coat-check ticket
- The picture: one login, then many requests
- Server sessions vs stateless JWTs
- The code: hash, verify, then issue a ticket
- Cookies done right (and refresh tokens)
- Logout and revocation, the part people skip
- Common mistakes that cost hours (or a breach)
- Takeaways
- Where to go next
You can verify a password. Now what?
Your login endpoint works. A user POSTs an email and password, you check them, and the response says "welcome back." Then the very next request comes in, GET /orders, and the server has no idea who is asking. HTTP is stateless: every request arrives as a stranger. The whole job of authentication in a backend is to turn one successful login into a stream of requests the server can keep trusting, without asking for the password again.
That sounds simple, and that is exactly why it goes wrong. People store passwords in plaintext. They put a secret token in localStorage where any script can read it. They issue tokens that never expire and can never be revoked, so a leaked credential is valid forever. This article is the backend implementation companion to Authentication vs Authorization, that piece explains the concepts; this one shows you how to actually build the login-and-session machinery without leaving a hole in it.
📌 Who this is for: Backend developers who can write a route handler but have never built auth from scratch, or have copy-pasted it and aren't sure which parts are load-bearing. You'll leave knowing how to hash a password, choose between sessions and JWTs, set cookies safely, and log a user out for real. No prior security background needed.
The mental model: a coat-check ticket
Authentication answers "who are you?" exactly once. Everything after that is the server recognizing a credential it already issued, so the design problem is really about that credential's lifetime, storage, and revocation.
Think of a coat check. You hand over your coat (your password) once, at the door. In return you get a little numbered ticket. For the rest of the night you never describe your coat again, you just flash the ticket and the attendant hands your coat back. The ticket is worthless to a thief who doesn't know which coat it maps to, it's small enough to carry around, and at the end of the night it stops working.
| In the real world | In tech |
|---|---|
| Handing over your coat at the door | POSTing your password to /login, once |
| The numbered ticket you get back | A session id or signed JWT |
| Flashing the ticket all night | Every later request carries the session cookie |
| The attendant's coat rack | Server-side session store (or the token's own signature) |
| Ticket stops working at closing time | Expiry, and the ability to revoke it early |
Logging in is a one-time exchange. The session id (or token) is the ticket you carry afterward.
The picture: one login, then many requests
Here is the flow end to end. The password is verified once; from then on, each request carries the ticket and the server only has to validate the ticket, a much cheaper, much safer operation.
Login verifies the password hash once; later requests carry and validate a session id or token.
- Client submits credentials: The browser POSTs email + password to /login over HTTPS. This is the only request where the raw password travels.
- Server verifies the password hash: Look up the user, then compare the submitted password against the stored hash with bcrypt or argon2. You never decrypt, hashes are one-way. The library tells you match or no-match.
- Server issues a ticket: On success, mint a credential: either a random session id stored server-side, or a signed JWT the client holds. Attach it to the response as a cookie.
- Client carries the ticket: The browser stores the cookie and automatically attaches it to every subsequent request to your domain, no app code required.
- Server validates each request: For a session id, look it up in the store and confirm it's live. For a JWT, verify the signature and expiry. If it checks out, you know who's asking.
Server sessions vs stateless JWTs
There are two ways to make a ticket the server trusts. With server sessions, the ticket is just a random id; the real data lives in a store on your side (Redis, a database table). With stateless JWTs, the ticket is the data, a signed blob the client holds, which the server trusts because the signature can't be forged without your secret key. The trade-off is the whole game, and it comes down to one question: how do you revoke?
| Dimension | Server sessions | Stateless JWT |
|---|---|---|
| Where state lives | Server store (Redis/DB); cookie holds only an opaque id | Inside the token itself; server stores nothing |
| Revocation | Instant, delete the row and the next request fails | Hard, valid until it expires unless you add a denylist |
| Scaling | Every node needs to reach the shared store | Any node can verify with just the signing key |
| Per-request cost | A store lookup on each request | A signature check (CPU only, no I/O) |
| Size on the wire | Tiny id (~32 bytes) | Bigger, header + claims + signature, sent every request |
| Best fit | Most web apps; anything needing instant logout | Short-lived API/service tokens, paired with a refresh token |
Server sessions vs JWTs, the practical trade-offs.
💡 Default to sessions: If you're unsure, use server sessions. "Stateless" sounds like less work, but the moment you need to log someone out, ban an account, or invalidate everything after a password change, statelessness becomes the problem you have to engineer around. Reach for JWTs when you genuinely can't share a session store, for example, tokens passed between independently deployed services.
The code: hash, verify, then issue a ticket
Below is the core of a login flow in Python. Step one is registration, we hash the password before it ever touches the database. Step two is login, we verify against that hash, then mint a session. Notice we never store, log, or compare the raw password directly.
auth.py
import bcrypt, secrets, time
# --- Registration: hash before storing. NEVER store the raw password. ---
def hash_password(plain: str) -> bytes:
# bcrypt auto-generates a random salt and embeds it in the output.
# The cost factor (12) makes each guess deliberately slow for attackers.
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=12))
# users table stores: id, email, password_hash (the bytes above)
# --- Login: verify against the stored hash. ---
def verify_password(plain: str, stored_hash: bytes) -> bool:
# Constant-time compare; safe against timing attacks. Returns True/False.
return bcrypt.checkpw(plain.encode(), stored_hash)
# --- Issue a server-side session (the coat-check ticket). ---
sessions = {} # in real life: Redis, with a TTL
def create_session(user_id: int) -> str:
sid = secrets.token_urlsafe(32) # 256 bits of randomness, unguessable
sessions[sid] = {"user_id": user_id, "expires": time.time() + 7 * 86400}
return sid
def validate_session(sid: str):
s = sessions.get(sid)
if not s or s["expires"] < time.time():
return None # expired or revoked -> reject
return s["user_id"]
def revoke_session(sid: str):
sessions.pop(sid, None) # logout = delete it. Done.
If you'd rather go stateless, the issue/validate pair becomes a JWT. The shape is the same, mint on login, check on each request, but there's no store, so revocation is no longer free:
jwt_auth.py
import jwt, time # PyJWT
SECRET = "load-this-from-an-env-var-not-source-code"
def issue_jwt(user_id: int) -> str:
payload = {
"sub": str(user_id),
"iat": int(time.time()),
"exp": int(time.time()) + 15 * 60, # short life: 15 minutes
}
return jwt.encode(payload, SECRET, algorithm="HS256")
def validate_jwt(token: str):
try:
# Verifies the signature AND the exp claim in one call.
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
return payload["sub"]
except jwt.InvalidTokenError:
return None # bad signature or expired -> reject
⚠️ Pin the algorithm: Always pass an explicit
algorithms=[...]allow-list when decoding a JWT. Libraries that accept the algorithm from the token's own header have shipped catastrophic bugs, an attacker setsalg: noneand the signature check is skipped entirely.
Cookies done right (and refresh tokens)
Whichever ticket you chose, deliver it in a cookie, and three flags decide whether that cookie is safe. Getting these wrong is how a working login becomes a vulnerability.
-
HttpOnly, JavaScript cannot read the cookie. This is the single most important flag: it means a cross-site scripting (XSS) bug can't steal the session. (It's also exactly why you should not keep tokens in
localStorage, that's readable by any script.) - Secure, the cookie is only sent over HTTPS, so it never travels in plaintext where it could be sniffed.
-
SameSite, controls whether the cookie rides along on cross-site requests.
Laxis a sane default and blocks most CSRF;Strictis tighter; only useNone(withSecure) when you genuinely need cross-site cookies. - Max-Age / Expires, give every cookie a finite lifetime. A ticket that never expires is a permanent key.
set_cookie.py
# Flask example, the same flags exist in every framework.
resp.set_cookie(
"session",
sid,
httponly=True, # JS can't touch it -> XSS can't steal it
secure=True, # HTTPS only
samesite="Lax", # CSRF protection
max_age=7 * 86400, # finite lifetime
)
Refresh tokens solve a tension with JWTs: you want access tokens to be short-lived (so a leak is brief), but you don't want to ask users to log in every 15 minutes. The pattern: issue a short-lived access token plus a long-lived refresh token. When the access token expires, the client trades the refresh token for a new one. Critically, the refresh token is stored server-side (or in a denylist), so it can be revoked, which buys back the logout ability that pure JWTs lack. Keep the refresh token in an HttpOnly cookie too, and rotate it on each use so a stolen one is detectable.
Logout and revocation, the part people skip
Logout is not "delete the cookie in the browser." A stolen cookie still works if the server still honors it. Real logout means invalidating the ticket server-side. With sessions this is one line, delete the row. With pure JWTs you can't, which is the whole catch: the token stays valid until it expires no matter what you do. That's why production JWT setups keep them short-lived and add a denylist of revoked token ids checked on each request, at which point you've re-added the server lookup you adopted JWTs to avoid. It's a real trade-off, not a free lunch.
logout.py
# Session logout: invalidate the ticket, then clear the cookie.
def logout(sid, resp):
revoke_session(sid) # server-side: the ticket is now dead
resp.delete_cookie("session")
return resp
# "Log out everywhere" = delete every session for the user_id.
# Trivial with sessions; needs a denylist with JWTs.
Common mistakes that cost hours (or a breach)
- Storing passwords in plaintext, or hashing with MD5/SHA-1. Those hashes are fast, which is exactly wrong: attackers brute-force them billions per second. Use bcrypt or argon2, which are deliberately slow and salted.
-
Putting tokens in
localStorage. It's readable by any JavaScript on the page, so one XSS bug exfiltrates every user's session. UseHttpOnlycookies instead. - No expiry. A session or token that lives forever turns a single leak into permanent access. Always set a finite lifetime.
- No revocation path. If you can't kill a session on logout, password change, or account ban, you can't respond to a compromise. Design revocation in from day one, it's the main reason to prefer sessions.
-
Accepting the JWT's own
algheader. Always pin the verification algorithm; never let the token tell you how to verify itself. - Returning different errors for "no such user" vs "wrong password." That leaks which emails are registered. Return one generic "invalid credentials" for both.
Takeaways
The whole article in seven lines
- HTTP is stateless, auth turns one login into a stream of trusted requests via a ticket.
- Never store raw passwords; hash with bcrypt or argon2 (slow + salted), never MD5/SHA-1.
- Server sessions = state on your side, instant revocation. JWTs = stateless, but revocation is hard.
- Default to sessions; reach for JWTs only when you can't share a session store.
- Deliver the ticket in an
HttpOnly+Secure+SameSitecookie with a finite lifetime, neverlocalStorage. - Refresh tokens let access tokens stay short-lived without forcing constant re-login.
- Logout means invalidating the ticket server-side, not just clearing the browser cookie.
Where to go next
Authentication answers "who are you?", the next question is "what are you allowed to do?" Read the concept companion to this piece, then see how auth fits into a hardened API surface, and continue along the Backend Engineer track.
- Authentication vs Authorization, the concepts behind everything you just built.
- Secure API Design, where sessions, tokens, and cookies fit into a safe API surface.
- Backend Engineer path, the full track this article belongs to.
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)