DEV Community

Cover image for Authentication & Sessions in Backends
Sri Balaji
Sri Balaji

Posted on Originally published at thesimplifiedtech.com

Authentication & Sessions in Backends

⚡ 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?

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.

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

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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

# 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
)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Common mistakes that cost hours (or a breach)

  1. 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.
  2. 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.
  3. No expiry. A session or token that lives forever turns a single leak into permanent access. Always set a finite lifetime.
  4. 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.
  5. Accepting the JWT's own alg header. Always pin the verification algorithm; never let the token tell you how to verify itself.
  6. 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.


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)