Users are logged out at random behind the load balancer
See the problem
Section titled “See the problem”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.
Define the task
Section titled “Define the task”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”.
Pause and think
Section titled “Pause and think”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.
Understand the idea
Section titled “Understand the idea”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:
- Your app makes a random verifier, and sends the user to Google with its hash, the challenge.
- The user logs in at Google, never at your app, and agrees.
- Google sends the user back to your app with a one-time code.
- 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.
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.
Climb the ladder
Section titled “Climb the ladder”Three ways to remember Ana. Each one is real and in use today. What changes is where the note lives.
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.
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 base64import hashlibimport hmacimport 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 secretsimport base64import hashlibimport hmacimport 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 siddef 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 serverdef 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 it yourself
Section titled “Write it yourself”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
algmust 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, returnsub.
Check a JWT
Ctrl + Enter runs- Not run yet:
Ana’s token, before it ends→'ana' - Not run yet:
Ben’s token, made by PyJWT→'ben' - Not run yet:
Claims changed to "admin", signature kept→ raisesValueError - Not run yet:
Ana’s token, at the second it ends→ raisesValueError - Not run yet:
A token that says "alg": "none", with no signature→ raisesValueError - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Lookup per request | Log out at once | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: memory | A dict | Yes | Simple, fast | One server only; a restart logs everyone out | Development, one small server |
| Better: shared store | One store call | Yes | Many servers; easy log out | The store must be fast and always up | Most websites (Django’s default) |
| Best: signed token | None | No | No store; works across services | Can’t take it back; must stay short | APIs for apps, between services, OAuth2 access tokens |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- 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. Usehmac.compare_digest.
Say it in the interview
Section titled “Say it in the interview”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.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. A safe session cookie (easy)
Section titled “1. A safe session cookie (easy)”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- Not run yet:
session_cookie('abc', 3600)→'session=abc; Max-Age=3600; Path=/; HttpOnly; Secure; SameSite=Lax' - 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
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.
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.
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.
2. Refresh tokens that work once (medium)
Section titled “2. Refresh tokens that work once (medium)”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 toused. - Raises
PermissionErrorfor 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- Not run yet:
A live token gives a new one, in the same family→[2, ['ana-phone'], {'r1': 'ana-phone'}] - Not run yet:
An unknown token→ raisesPermissionError - Not run yet:
r1 is used, then comes back: the phone’s tokens end, the laptop stays→[True, {'x1': 'ana-laptop'}] - 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
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.
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), newWhere 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.
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), newWhen 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.
3. The PKCE challenge (medium)
Section titled “3. The PKCE challenge (medium)”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- Not run yet:
The verifier from RFC 7636→'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' - Not run yet:
A verifier of 9 characters→ raisesValueError - Not run yet: Extra test
- 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
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.
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.
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 base64import hashlibimport 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 base64import hashlibimport 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.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- 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.
Go deeper
Section titled “Go deeper”- RFC 7519, JSON Web Token, and PyJWT’s docs.
- RFC 7636, PKCE, and the OAuth 2.0 Security Best Current Practice.
- OWASP’s session management cheat sheet.
- Django: how to use sessions.
- Next on the Frameworks roadmap: CORS and CSRF, the browser rules that decide which sites may send these cookies.
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