Skip to content

Users are logged out at random behind the load balancer

  • Frameworks · Foundations
  • 25 min read, about 1 h 25 min with practice
  • Concept
  • Asked often: high
  • Covers JWT RFC 7519, OAuth 2.0 with PKCE (RFC 7636), PyJWT 2.15, Python 3.11+
  • Reviewed 2026-11

Your notes app grew, so you added a second server and a load balancer in front. The next morning, support is busy. Users say they are logged out at random, about every other click. Nothing in the code changed.

The app keeps logins in a dict in the server’s memory. Server A wrote down Ana’s login. When the load balancer sends her next click to server B, B has never heard of her.

HTTP has no memory: each request stands alone. So after Ana logs in, every request must carry something that proves it’s her.

  • What the browser sends: a session id in a cookie, or a signed token in a header.
  • What the server needs: to find out who it is, on any server, fast.
  • What else matters: logging out, ending old logins, and what happens if the cookie or token is stolen.
  • The hard part: know who is asking on every server, without a lookup on every request if possible, and still be able to log someone out.

Example: Ana logs in on server A and gets session=abc. Her next request lands on server B. B must answer “this is Ana”.

Hints open one at a time:

Hint 1 of 3

Somewhere both servers can reach: a shared store, like Redis or the database.

Hint 2 of 3

Or in the request itself. But then the user could just write “I am the admin”...

Hint 3 of 3

...unless the server signs it with a secret only servers know. A changed byte breaks the signature.

A session is a note the server keeps. At login, the server makes a long random id, saves “abc is Ana” somewhere, and sends the id in a cookie. The browser sends the cookie back with every request to that site. Where the server keeps the note decides whether it works with many servers.

A token is a note the browser keeps. A JWT (JSON Web Token) is three base64url parts joined by dots: a header, the claims (like {"sub": "ana", "exp": 1900}) and a signature. The signature is an HMAC: a hash of the first two parts, made with a secret. Any server with the secret can check it, so no store is needed.

A JWT is signed, not hidden. Anyone can decode the claims. Never put secrets in it. And once given, it is valid until it ends: you can’t take it back without a store, which is what it was meant to avoid. So access tokens are short, about 15 minutes, and a refresh token gets a new one.

OAuth2 is how one site lets another act for you. “Log in with Google” is OAuth2 with OpenID Connect on top. The safe flow for websites and apps is the authorization code flow with PKCE:

  1. Your app makes a random verifier, and sends the user to Google with its hash, the challenge.
  2. The user logs in at Google, never at your app, and agrees.
  3. Google sends the user back to your app with a one-time code.
  4. Your app swaps the code, plus the verifier, for tokens. Google checks that the verifier matches the challenge.

A stolen code is useless without the verifier, which never left your app.

Ana’s login on two servers

Sessions in each server’s memory

Parts:

  • Browser
  • Load balancer
  • Server A (the request is here)
  • Server B

Arrows:

  • Browser sends requests to Load balancer (the request went this way)
  • Load balancer sends requests to Server A (the request went this way)
  • Load balancer sends requests to Server B

Step 1 of 8. Ana logs in. The load balancer sends her to server A. A remembers “session abc is Ana” in its memory, and sends a cookie: session=abc.

Three ways to remember Ana. Each one is real and in use today. What changes is where the note lives.

Okay

Sessions in the server’s memory

The idea: At login, make a random id, save id → user in a dict, and send the id in a cookie.

With one server, it works well. Logging out is one del.

import secrets
class MemorySessions:
"""Okay: each server keeps its sessions in its own memory."""
def __init__(self) -> None:
self.sessions: dict[str, str] = {}
def login(self, user: str) -> str:
sid = secrets.token_urlsafe(32) # goes in a cookie
self.sessions[sid] = user
return sid
def who(self, sid: str) -> str | None:
return self.sessions.get(sid)
Lookup
A dict, in memory
Works with
One server
Why
Fast and simple, and the default in many frameworks’ development setups.

Where it breaks: two servers. Server B can’t see server A’s dict, so Ana is logged out whenever the load balancer picks B. A restart also logs everyone out. “Sticky sessions” pin a user to one server, but uneven load and a crashed server bring it back.

Better

A shared session store

The idea: Keep id → (user, expiry) in one store every server reads, like Redis or a database table.

The waste in Okay: the note was written where only one server could read it.

This is what Django does by default, with a database table, and what most websites should use.

Highlighted lines are new in this rung.

import secrets
class StoreSessions:
"""Better: every server reads one shared session store, like Redis or a database table."""
def __init__(self, store: dict, ttl: int = 3600) -> None:
self.store = store # shared by every server
self.ttl = ttl
def login(self, user: str, now: int) -> str:
sid = secrets.token_urlsafe(32)
self.store[sid] = (user, now + self.ttl)
return sid
def who(self, sid: str, now: int) -> str | None:
user, expires = self.store.get(sid, (None, 0))
return user if now < expires else None
def logout(self, sid: str) -> None:
self.store.pop(sid, None) # works at once, on every server
Lookup
One store call per requestwas A dict, in memory
Works with
Any number of serverswas One server
Why
Any server finds the session, and deleting it logs the user out everywhere at once.
What changed from the previous rung
import secrets
class MemorySessions:
"""Okay: each server keeps its sessions in its own memory."""
class StoreSessions:
"""Better: every server reads one shared session store, like Redis or a database table."""
def __init__(self) -> None:
self.sessions: dict[str, str] = {}
def __init__(self, store: dict, ttl: int = 3600) -> None:
self.store = store # shared by every server
self.ttl = ttl
def login(self, user: str) -> str:
sid = secrets.token_urlsafe(32) # goes in a cookie
self.sessions[sid] = user
def login(self, user: str, now: int) -> str:
sid = secrets.token_urlsafe(32)
self.store[sid] = (user, now + self.ttl)
return sid
def who(self, sid: str) -> str | None:
return self.sessions.get(sid)
def who(self, sid: str, now: int) -> str | None:
user, expires = self.store.get(sid, (None, 0))
return user if now < expires else None
def logout(self, sid: str) -> None:
self.store.pop(sid, None) # works at once, on every server

Where it breaks: every request now waits for the store, and the store must stay up. For many separate services, each one needs to reach the same store, or call an auth service.

Best

A signed token (JWT)

The idea: At login, sign “this is Ana, until 10:15” with a secret. Any server checks the signature and the time, with no lookup.

The waste in Better: a network call on every request, just to read who it is.

The tests check these tokens with PyJWT, a widely used library, so they are real JWTs. Note hmac.compare_digest: it takes the same time whether the first byte or the last differs, so timing gives nothing away.

Highlighted lines are new in this rung.

import base64
import hashlib
import hmac
import json
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def sign(message: str, secret: str) -> str:
return b64(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest())
def make_token(user: str, secret: str, now: int, ttl: int = 900) -> str:
"""A JWT signed with HS256: header.payload.signature, each part base64url."""
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({"sub": user, "exp": now + ttl}).encode())
return f"{header}.{payload}.{sign(f'{header}.{payload}', secret)}"
def read_token(token: str, secret: str, now: int) -> str:
"""Best: any server with the secret checks the signature and the expiry. No store."""
try:
header, payload, signature = token.split(".")
except ValueError:
raise ValueError("bad token") from None
if json.loads(unb64(header)).get("alg") != "HS256":
raise ValueError("bad token") # never let the token pick "none"
if not hmac.compare_digest(signature, sign(f"{header}.{payload}", secret)):
raise ValueError("bad signature")
claims = json.loads(unb64(payload))
if now >= claims["exp"]:
raise ValueError("expired")
return claims["sub"]
Lookup
None: a hashwas One store call per request
Works with
Any server or service with the secretwas Any number of servers
Why
The note travels with the request, and the signature proves the server wrote it.
What changed from the previous rung
import secrets
import base64
import hashlib
import hmac
import json
class StoreSessions:
"""Better: every server reads one shared session store, like Redis or a database table."""
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def __init__(self, store: dict, ttl: int = 3600) -> None:
self.store = store # shared by every server
self.ttl = ttl
def login(self, user: str, now: int) -> str:
sid = secrets.token_urlsafe(32)
self.store[sid] = (user, now + self.ttl)
return sid
def unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def who(self, sid: str, now: int) -> str | None:
user, expires = self.store.get(sid, (None, 0))
return user if now < expires else None
def logout(self, sid: str) -> None:
self.store.pop(sid, None) # works at once, on every server
def sign(message: str, secret: str) -> str:
return b64(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest())
def make_token(user: str, secret: str, now: int, ttl: int = 900) -> str:
"""A JWT signed with HS256: header.payload.signature, each part base64url."""
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({"sub": user, "exp": now + ttl}).encode())
return f"{header}.{payload}.{sign(f'{header}.{payload}', secret)}"
def read_token(token: str, secret: str, now: int) -> str:
"""Best: any server with the secret checks the signature and the expiry. No store."""
try:
header, payload, signature = token.split(".")
except ValueError:
raise ValueError("bad token") from None
if json.loads(unb64(header)).get("alg") != "HS256":
raise ValueError("bad token") # never let the token pick "none"
if not hmac.compare_digest(signature, sign(f"{header}.{payload}", secret)):
raise ValueError("bad signature")
claims = json.loads(unb64(payload))
if now >= claims["exp"]:
raise ValueError("expired")
return claims["sub"]

When Best isn’t best: you can’t log a token out. A stolen token works until it ends, and a fired admin keeps access for those minutes. For a normal website, a session in a shared store with an HttpOnly cookie is simpler and safer. Use JWTs between services, for mobile apps, and as OAuth2 access tokens, with short lives.

Write read_token(token, secret, now), the check every server runs. make_token is given, so you can see how a token is made. Press Run tests to check your code.

  • Split the token into header, claims and signature. Anything else: raise ValueError("bad token").
  • The header’s alg must be "HS256". Anything else, "none" included: ValueError("bad token").
  • Work out the signature again and compare with hmac.compare_digest. No match: ValueError("bad signature").
  • If now >= exp: ValueError("expired"). Otherwise, return sub.

Check a JWT

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Ana’s token, before it ends → 'ana'
  2. Not run yet: Ben’s token, made by PyJWT → 'ben'
  3. Not run yet: Claims changed to "admin", signature kept → raises ValueError
  4. Not run yet: Ana’s token, at the second it ends → raises ValueError
  5. Not run yet: A token that says "alg": "none", with no signature → raises ValueError
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayLookup per requestLog out at onceGoodNot so goodUse it when
Okay: memoryA dictYesSimple, fastOne server only; a restart logs everyone outDevelopment, one small server
Better: shared storeOne store callYesMany servers; easy log outThe store must be fast and always upMost websites (Django’s default)
Best: signed tokenNoneNoNo store; works across servicesCan’t take it back; must stay shortAPIs for apps, between services, OAuth2 access tokens
  • Never trust the token’s alg. Accept only the algorithm you use. Old libraries took "alg": "none" as “no signature needed”.
  • Use a long random secret. HS256 wants at least 32 random bytes. A short or guessable secret can be found by trying words.
  • A JWT is readable. Anyone can decode the claims, so put an id in it, not an email address or a role you must keep secret.
  • Logging out a JWT needs a store after all. Keep a deny-list of token ids until they end, or keep access tokens very short.
  • Rotate the session id at login. Give a new session id after login, so an id planted before login is worthless. Django does this for you.
  • Compare secrets in constant time. == stops at the first different byte, which can leak how much matched. Use hmac.compare_digest.

Questions to ask first

  • Is it a website, a mobile app, or services calling each other?
  • How quickly must a log out or a ban take effect?
  • Is there one app, or many services that each need to know the user?

A 60-second walkthrough

“HTTP has no memory, so each request must prove who sent it. For a website, I’d use a server-side session: a random id in an HttpOnly, Secure, SameSite cookie, with the data in a shared store like Redis or the database. That works on every server, and logging out deletes it at once. An in-memory session breaks as soon as there are two servers behind a load balancer. For mobile apps or many services, I’d use a short-lived JWT signed by the auth service. Any service checks it with no lookup. But it can’t be revoked, so it lasts about 15 minutes, and a rotating refresh token gets a new one. For “Log in with Google”, it’s OAuth2’s authorization code flow with PKCE.”

Likely follow-ups

  • Authentication vs authorization? Authentication finds out who you are. Authorization decides what you may do.
  • HS256 or RS256? HS256 uses one shared secret, so every service that checks can also sign. RS256 signs with a private key and checks with a public key, so only the auth service can make tokens.
  • What is a refresh token? A long-lived token that only gets new access tokens. It works once and is swapped for a new one. The second practice problem builds it.
  • Why PKCE? An app on a phone can’t keep a secret. PKCE proves that the app swapping the code is the one that asked for it.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

Write session_cookie(sid, max_age). It gives back the value of a Set-Cookie header for the cookie session. Include: Max-Age, Path=/, HttpOnly, Secure and SameSite=Lax, joined by "; ".

Example: session_cookie("abc", 3600) is "session=abc; Max-Age=3600; Path=/; HttpOnly; Secure; SameSite=Lax".

Hint 1 of 3

HttpOnly: JavaScript on the page can’t read the cookie, so a script bug can’t steal it.

Hint 2 of 3

Secure: the browser sends it only over HTTPS. SameSite=Lax: not on most requests from other sites.

Hint 3 of 3

Max-Age: the browser drops it after that many seconds.

A safe session cookie

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: session_cookie('abc', 3600) → 'session=abc; Max-Age=3600; Path=/; HttpOnly; Secure; SameSite=Lax'
  2. Not run yet: Every flag is there → ['HttpOnly', 'Max-Age=60', 'Path=/', 'SameSite=Lax', 'Secure']
Try your own input
Show the Okay, Better and Best answers
Okay

Name and value

The idea: session=abc, with no flags.

It does keep Ana logged in.

def session_cookie(sid: str, max_age: int) -> str:
"""Okay: just the name and the value."""
return f"session={sid}"
Blocks
Nothing
Lasts
Until the browser closes
Why
The browser sends it back, so logins work.

Where it breaks: any script on the page can read it, it is sent over plain HTTP, and other sites can make requests that carry it.

Better

HttpOnly

The idea: Add HttpOnly and Path=/.

The waste in Okay: the most stolen cookie on the site had no protection.

Highlighted lines are new in this rung.

def session_cookie(sid: str, max_age: int) -> str:
"""Better: HttpOnly, so JavaScript on the page can't read it."""
return f"session={sid}; HttpOnly; Path=/"
Blocks
Scripts reading itwas Nothing
Lasts
Until the browser closes
Why
A cross-site scripting bug can no longer read the cookie.
What changed from the previous rung
def session_cookie(sid: str, max_age: int) -> str:
"""Okay: just the name and the value."""
return f"session={sid}"
"""Better: HttpOnly, so JavaScript on the page can't read it."""
return f"session={sid}; HttpOnly; Path=/"

Where it breaks: it still goes over plain HTTP, other sites can still send it, and it has no end date.

Best

HttpOnly, Secure, SameSite, Max-Age

The idea: All four flags, plus Path=/.

The waste in Better: it fixed one leak and left three.

Highlighted lines are new in this rung.

def session_cookie(sid: str, max_age: int) -> str:
"""Best: HttpOnly, Secure, SameSite=Lax, and an end date."""
return f"session={sid}; Max-Age={max_age}; Path=/; HttpOnly; Secure; SameSite=Lax"
Blocks
Scripts, HTTP, most cross-site requestswas Scripts reading it
Lasts
Max-Age secondswas Until the browser closes
Why
Each flag closes one way the cookie could leak or be misused.
What changed from the previous rung
def session_cookie(sid: str, max_age: int) -> str:
"""Better: HttpOnly, so JavaScript on the page can't read it."""
return f"session={sid}; HttpOnly; Path=/"
"""Best: HttpOnly, Secure, SameSite=Lax, and an end date."""
return f"session={sid}; Max-Age={max_age}; Path=/; HttpOnly; Secure; SameSite=Lax"

When Best isn’t best: let the framework write it. Django’s SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY and SESSION_COOKIE_SAMESITE, or Flask’s SESSION_COOKIE_* settings, set these for every response.

Write refresh(token, store, used). store maps each live refresh token to its family, like "ana-phone". used maps tokens already spent to their family. A refresh:

  • Swaps a live token for a new one in the same family, and returns (access_token, new_refresh_token). The old one moves to used.
  • Raises PermissionError for a token that is neither live nor used.
  • If a used token comes back, someone copied it. Delete every live token of that family, then raise PermissionError.

Example: r1 gives r2. Then r1 comes back. Both are dead, and the user must log in again on that device.

Hint 1 of 3

Check used first. A replayed token is the alarm, even if a newer token in its family is still live.

Hint 2 of 3

To end a family, collect the live tokens whose family matches, then delete them. Don’t delete from a dict while you loop over it.

Hint 3 of 3

secrets.token_urlsafe(16) makes a new random token.

Refresh tokens that work once

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: A live token gives a new one, in the same family → [2, ['ana-phone'], {'r1': 'ana-phone'}]
  2. Not run yet: An unknown token → raises PermissionError
  3. Not run yet: r1 is used, then comes back: the phone’s tokens end, the laptop stays → [True, {'x1': 'ana-laptop'}]
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

A token that works for ever

The idea: If the token is in the store, give a new access token. Keep the refresh token as it is.

Short access tokens are now possible, and the user stays logged in.

import secrets
def refresh(token: str, store: dict) -> str:
"""Okay: a refresh token works again and again, and gives a new access token each time."""
if token not in store:
raise PermissionError("log in again")
return "access-" + secrets.token_hex(8)
A stolen copy
Works for ever
Detects theft
Never
Why
The same refresh token can be used again and again.

Where it breaks: a copied refresh token gives the thief new access tokens for as long as it lives, and nobody notices.

Better

Rotation: each token works once

The idea: Swap the refresh token for a new one each time, and forget the old one.

The waste in Okay: one token, used without end.

Highlighted lines are new in this rung.

import secrets
def refresh(token: str, store: dict) -> tuple[str, str]:
"""Better: each refresh token works once, and is swapped for a new one (rotation)."""
family = store.pop(token, None)
if family is None:
raise PermissionError("log in again")
new = secrets.token_urlsafe(16)
store[new] = family
return "access-" + secrets.token_hex(8), new
A stolen copy
Works until the next refreshwas Works for ever
Detects theft
Never
Why
An old token is gone, so a copy made earlier stops working.
What changed from the previous rung
import secrets
def refresh(token: str, store: dict) -> str:
"""Okay: a refresh token works again and again, and gives a new access token each time."""
if token not in store:
def refresh(token: str, store: dict) -> tuple[str, str]:
"""Better: each refresh token works once, and is swapped for a new one (rotation)."""
family = store.pop(token, None)
if family is None:
raise PermissionError("log in again")
return "access-" + secrets.token_hex(8)
new = secrets.token_urlsafe(16)
store[new] = family
return "access-" + secrets.token_hex(8), new

Where it breaks: if the thief refreshes first, the real app’s token is the one that fails. The thief keeps going, and the user is just logged out.

Best

Rotation with reuse detection

The idea: Remember spent tokens. If one comes back, end every token of that family.

The waste in Better: a replayed token is a clear sign of theft, and Better ignored it.

Highlighted lines are new in this rung.

import secrets
def refresh(token: str, store: dict, used: dict) -> tuple[str, str]:
"""Best: rotate, and if a used token comes back, someone copied it: end the whole family."""
if token in used: # a thief or the real user is replaying an old token
family = used[token]
for t in [t for t, f in store.items() if f == family]:
del store[t]
raise PermissionError("log in again")
family = store.pop(token, None)
if family is None:
raise PermissionError("log in again")
used[token] = family
new = secrets.token_urlsafe(16)
store[new] = family
return "access-" + secrets.token_hex(8), new
A stolen copy
Ends the family on first reusewas Works until the next refresh
Detects theft
Yeswas Never
Why
Only a copy can bring back a spent token, so a replay proves theft. Whoever had it, both sides are cut off.
What changed from the previous rung
import secrets
def refresh(token: str, store: dict) -> tuple[str, str]:
"""Better: each refresh token works once, and is swapped for a new one (rotation)."""
def refresh(token: str, store: dict, used: dict) -> tuple[str, str]:
"""Best: rotate, and if a used token comes back, someone copied it: end the whole family."""
if token in used: # a thief or the real user is replaying an old token
family = used[token]
for t in [t for t, f in store.items() if f == family]:
del store[t]
raise PermissionError("log in again")
family = store.pop(token, None)
if family is None:
raise PermissionError("log in again")
used[token] = family
new = secrets.token_urlsafe(16)
store[new] = family
return "access-" + secrets.token_hex(8), new

When Best isn’t best: a phone on a bad network may send the same refresh twice and log itself out. Many services allow a few seconds of grace for that.

Write challenge_for(verifier), the S256 method from RFC 7636. The verifier must be 43 to 128 characters from A–Z a–z 0–9 - . _ ~, or raise ValueError. The challenge is the SHA-256 of the verifier, in base64url, with the = padding removed.

Example: the RFC’s own verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" gives "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".

Hint 1 of 3

hashlib.sha256(text.encode()).digest() gives 32 raw bytes, not hex.

Hint 2 of 3

base64.urlsafe_b64encode uses - and _ instead of + and /. Then strip the trailing =.

Hint 3 of 3

Check the verifier first with re.fullmatch(r"[A-Za-z0-9\-._~]{43,128}", verifier).

The PKCE challenge

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: The verifier from RFC 7636 → 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
  2. Not run yet: A verifier of 9 characters → raises ValueError
  3. Not run yet: Extra test
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

The plain method

The idea: The challenge is the verifier itself.

RFC 7636 allows it, as plain, for apps that can’t hash.

def challenge_for(verifier: str) -> str:
"""Okay: the "plain" method. The challenge is the verifier itself."""
return verifier
Seeing the challenge
Gives away the verifier
Servers accept it
Some, as “plain”
Why
It ties the code to the app, but sends the secret in the open.

Where it breaks: the challenge travels through the browser. Anyone who sees it has the verifier too.

Better

A SHA-256 in hex

The idea: Send hashlib.sha256(verifier).hexdigest().

The waste in Okay: it sent the secret where it could be seen.

Highlighted lines are new in this rung.

import hashlib
def challenge_for(verifier: str) -> str:
"""Better: send a hash, so seeing the challenge doesn't give away the verifier."""
return hashlib.sha256(verifier.encode()).hexdigest()
Seeing the challenge
Gives away nothingwas Gives away the verifier
Servers accept it
Nonewas Some, as “plain”
Why
A hash can’t be turned back into the verifier.
What changed from the previous rung
import hashlib
def challenge_for(verifier: str) -> str:
"""Okay: the "plain" method. The challenge is the verifier itself."""
return verifier
"""Better: send a hash, so seeing the challenge doesn't give away the verifier."""
return hashlib.sha256(verifier.encode()).hexdigest()

Where it breaks: the standard says base64url, not hex. Google and every other server compute it the standard way, so the swap fails.

Best

S256, as the standard says

The idea: Check the verifier, then base64url the SHA-256 bytes and strip the padding.

The waste in Better: the right idea, in a format no server reads.

Highlighted lines are new in this rung.

import base64
import hashlib
import re
VERIFIER = re.compile(r"[A-Za-z0-9\-._~]{43,128}")
def challenge_for(verifier: str) -> str:
"""Best: S256 from RFC 7636: base64url of the SHA-256, with no padding."""
if not VERIFIER.fullmatch(verifier):
raise ValueError("a verifier is 43 to 128 letters, digits or -._~")
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
Seeing the challenge
Gives away nothing
Servers accept it
Allwas None
Why
It matches the RFC’s own example byte for byte.
What changed from the previous rung
import base64
import hashlib
import re
VERIFIER = re.compile(r"[A-Za-z0-9\-._~]{43,128}")
def challenge_for(verifier: str) -> str:
"""Better: send a hash, so seeing the challenge doesn't give away the verifier."""
return hashlib.sha256(verifier.encode()).hexdigest()
"""Best: S256 from RFC 7636: base64url of the SHA-256, with no padding."""
if not VERIFIER.fullmatch(verifier):
raise ValueError("a verifier is 43 to 128 letters, digits or -._~")
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

When Best isn’t best: use an OAuth library, like Authlib, which makes the verifier, the challenge and the state value for you.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choiceTwo servers sit behind a load balancer, and users are logged out every few clicks. What is the most likely cause?
  2. Pick the patternAn admin is fired and must lose access within seconds. Which works?
  3. Spot the bugWhat is wrong with this check?
    header, payload, sig = token.split(".")
    alg = json.loads(unb64(header))["alg"]
    if alg == "none" or sig == sign(f"{header}.{payload}", SECRET):
        return json.loads(unb64(payload))["sub"]
  4. Multiple choiceWhat does PKCE protect in the OAuth2 code flow?
  5. Fill in the answerThe cookie flag that stops JavaScript on the page from reading a cookie: ____

Add these to your review deck. Each card comes back a little later every time you get it right.

  • Sessions in one server’s memory break behind a load balancer. Use a shared store.
  • Session: the server keeps the note, so log out is instant. JWT: the browser keeps a signed note, so there is no lookup and no log out.
  • Keep JWTs short (about 15 minutes), check alg, use a long secret, and rotate refresh tokens.
  • Cookies: HttpOnly; Secure; SameSite=Lax, and an end date.
  • “Log in with X” is OAuth2’s authorization code flow with PKCE, plus OpenID Connect.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started