The practical backend side of logging users in: hashing passwords, choosing between server sessions and JWTs, setting cookies safely, and actually being able to log people out.
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.
Handing over your coat at the doorPOSTing your password to /login, once
The numbered ticket you get backA session id or signed JWT
Flashing the ticket all nightEvery later request carries the session cookie
The attendant's coat rackServer-side session store (or the token's own signature)
Ticket stops working at closing timeExpiry, 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.
1
Client submits credentials
The browser POSTs email + password to /login over HTTPS. This is the only request where the raw password travels.
2
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.
3
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.
4
Client carries the ticket
The browser stores the cookie and automatically attaches it to every subsequent request to your domain, no app code required.
5
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
python
import bcrypt, secrets, time
# --- Registration: hash before storing. NEVER store the raw password. ---defhash_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. ---defverify_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 TTLdefcreate_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
defvalidate_session(sid: str):
s = sessions.get(sid)
ifnot s or s["expires"] < time.time():
returnNone# expired or revoked -> rejectreturn s["user_id"]
defrevoke_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
python
import jwt, time # PyJWT
SECRET = "load-this-from-an-env-var-not-source-code"defissue_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")
defvalidate_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:
returnNone# 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 sets `alg: none` and 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. Lax is a sane default and blocks most CSRF; Strict is tighter; only use None (with Secure) 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
python
# 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
python
# Session logout: invalidate the ticket, then clear the cookie.deflogout(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. Use HttpOnly cookies 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 `alg` header. 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` + `SameSite` cookie with a finite lifetime, never `localStorage`.
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.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.