Split auth into its own service that signs with a private key and publishes a public one. This is what a managed identity provider does — built locally, so it stops being a black box.
Module 07 works. It is also the last version of auth that survives a second service, and the reason is arithmetic, not taste.
JWT_SECRET in .env, read by app/auth.py in both the login endpoint and the request dependency.Sit with the problem before reaching for the fix. You have one API today. Tomorrow you add a small analytics service that needs to know which user is calling it. It must verify tokens, so you copy JWT_SECRET into its environment. That copy can now issue tokens too — including one that says sub: 1, role: admin. You did not intend to grant that. The secret does not distinguish between "may check" and "may create", because in symmetric cryptography there is no such distinction.
Scale that to three services and the consequence is concrete: a compromise of your least important service — the one an intern deployed, the one with no rate limiting, the one whose logs nobody reads — forges valid admin tokens for every service in the estate. Your security is now the security of your weakest member. That is the failure this module removes.
RS256: two keys, one direction. RS256 is RSA signing with SHA-256. It uses a keypair — two mathematically linked files. The private key produces signatures. The public key can check a signature against the data it covers, and that is all it can do. Given the public key and a million valid tokens, you still cannot produce a new signature. The direction is one-way by construction.
So the deployment shape becomes: exactly one service holds private-key-1.pem. Every other service holds the public key — and because the public key grants no forging power, publishing it on the open internet costs you nothing. That is not a compromise; it is the design.
# from your project root. openssl is already on macOS, Linux and WSL.
mkdir -p keys
openssl genrsa -out keys/private-key-1.pem 2048
# writing RSA key <- the only output. The file is now ~1700 bytes.
openssl rsa -in keys/private-key-1.pem -pubout -out keys/public-key-1.pem
# writing RSA key
head -1 keys/private-key-1.pem
# -----BEGIN PRIVATE KEY-----
head -1 keys/public-key-1.pem
# -----BEGIN PUBLIC KEY-----
No account, no install, no network — the keypair is generated from random numbers on your own machine. The 2048 is the modulus size in bits; it is the current sane floor, and 4096 is the paranoid option that makes every verification measurably slower.
keys/*private* to .gitignore before you generate anything. A committed private key is a committed secret with git's permanent memory behind it — the same rule from Module 00, with higher stakes, because this one mints identities rather than opening one database. The public key is genuinely safe to commit; commit it if you like.
-----BEGIN RSA PRIVATE KEY----- and you see -----BEGIN PRIVATE KEY-----, nothing is wrong. OpenSSL 3 writes the newer PKCS#8 wrapper by default. Both hold the same key and every library in this module reads both.
users table, check passwords, and sign tokens.auth_service/ folder beside app/, running on port 8001 while the City Guide API keeps port 8000.city-guide/
app/ # the City Guide API - Modules 02-07. Port 8000.
main.py
auth.py # rewritten in this module: verify only
auth_service/ # NEW. Port 8001.
__init__.py
main.py # register, login, JWKS
keys.py # loads the PEM files, builds the JWK
keys/
private-key-1.pem # git-ignored
public-key-1.pem
Both services still point at the same Postgres for now, and that is a deliberate half-step. What changes today is ownership: auth_service is the only code that reads or writes the users table for authentication purposes. The City Guide API stops querying users to answer "who is this?" — it reads sub out of a verified token and believes it. That single change is what makes the two services separable later.
# auth_service/main.py
import time, uuid, os
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.db import get_db # the dependency from Module 05
from app.models import User # the model from Module 07
from auth_service.keys import PRIVATE_KEY, KID, public_jwks
ISSUER = os.environ["AUTH_ISSUER"] # http://localhost:8001
AUDIENCE = os.environ["AUTH_AUDIENCE"] # city-guide-api
TOKEN_TTL_SECONDS = 900 # 15 minutes
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
api = FastAPI(title="City Guide Identity")
def make_token(user: User) -> str:
now = int(time.time())
claims = {
"iss": ISSUER, # who minted this
"aud": AUDIENCE, # who may accept it
"sub": str(user.id), # who it is about
"iat": now, # when it was minted
"exp": now + TOKEN_TTL_SECONDS, # when it stops being valid
"jti": str(uuid.uuid4()), # unique id for this token
}
return jwt.encode(claims, PRIVATE_KEY, algorithm="RS256",
headers={"kid": KID})
@api.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form.username).first()
if not user or not pwd.verify(form.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED,
"Incorrect email or password")
return {"access_token": make_token(user), "token_type": "bearer"}
@api.get("/.well-known/jwks.json")
def jwks():
return public_jwks()
The five claims, and why each earns its place. A JWT payload is arbitrary JSON, so nothing forces you to include these — but every one of them closes a specific hole.
| Claim | Means | What goes wrong without it |
|---|---|---|
sub | Subject — the user id this token is about | You have proof of a valid token and no idea who it belongs to. |
iss | Issuer — the URL of the service that signed it | You cannot tell which of several identity providers minted this, so you cannot know which key set to check it against. |
aud | Audience — the service allowed to accept it | A token you hand to a low-trust partner service can be replayed by that service against your admin API. It verifies perfectly — same signer, same key. |
iat | Issued at — a Unix timestamp | You cannot answer "reject anything minted before the breach", and you cannot debug clock problems. |
exp | Expiry — a Unix timestamp | A stolen token is valid forever. This is the single most important claim in the token. |
aud deserves the extra beat, because it is the claim beginners drop first. Signature verification answers "did my identity provider mint this?" — it does not answer "was it minted for me?". Those are different questions. Without aud, any service that can obtain a token can spend it anywhere the same signer is trusted, and the confused-deputy attack writes itself.
kid is not a claim. It lives in the JWT header, not the payload, alongside alg and typ. That placement is deliberate: a verifier must choose a key before it can check the signature, so the key hint has to sit outside the signed-payload reading step. It is still covered by the signature, so nobody can rewrite it undetected.
exp — there is no database lookup to make it stop working. Short expiry is the whole revocation story here, and long-lived tokens quietly turn a single stolen laptop into a month of access. Refresh tokens are the standard answer and are out of scope; the discipline that is in scope is keeping exp short.
{"keys": [...]}.GET endpoint, conventionally at /.well-known/jwks.json.The problem JWKS solves is distribution. You have a public key; five services need it; keys will change. Copying a PEM into five deployments is a manual step that will be wrong within a quarter. So the issuer serves its own public keys over HTTP, and verifiers fetch them. Because a public key is safe to hand to strangers, this endpoint needs no authentication at all.
An RSA public key is two numbers — the modulus n (a 2048-bit integer) and the public exponent e (almost always 65537). PEM is those two numbers wrapped in ASN.1 and base64. A JWK is the same two numbers, base64url-encoded, in a JSON object. Nothing is added or lost in the translation.
# auth_service/keys.py
import base64, os
from cryptography.hazmat.primitives import serialization
KID = os.environ["AUTH_KID"] # "key-1"
PRIVATE_KEY = open(os.environ["AUTH_PRIVATE_KEY_PATH"]).read()
def _b64u_uint(value: int) -> str:
"""Big-endian bytes, base64url, no '=' padding - as RFC 7517 requires."""
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _jwk_from_pem(path: str, kid: str) -> dict:
with open(path, "rb") as fh:
pub = serialization.load_pem_public_key(fh.read())
numbers = pub.public_numbers()
return {
"kty": "RSA", # key type
"use": "sig", # this key is for signatures, not encryption
"alg": "RS256", # the algorithm it is used with
"kid": kid, # which key this is
"n": _b64u_uint(numbers.n), # modulus - 342 chars for a 2048-bit key
"e": _b64u_uint(numbers.e), # exponent - "AQAB" is 65537
}
# a list, because during rotation there is more than one valid key
PUBLIC_KEY_PATHS = [(os.environ["AUTH_PUBLIC_KEY_PATH"], KID)]
def public_jwks() -> dict:
return {"keys": [_jwk_from_pem(p, k) for p, k in PUBLIC_KEY_PATHS]}
That endpoint returns exactly this shape — the n below is real output, truncated in the middle so it fits the page:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-1",
"n": "qC_wnNbgtz-Aek0wBMwMnqhyWil9KBxG8IzS73MRp8rVMiCvir7ldI8m…yHFSKP4GDQ",
"e": "AQAB"
}
]
}
kid is a label you choose, not a computed value. "key-1" is fine. Real providers often use a hash of the key. Its only job is to be unique across your keys and stable for the life of one key."keys" is a list because more than one key can be valid at once. If you only ever expect one entry, you have not thought about rotation yet — and the next section is entirely about that.e is "AQAB" on nearly every key you will ever see. That is base64url of the three bytes 01 00 01, which is 65537. Seeing anything else is a signal something unusual is happening..well-known is a convention, not a requirement. RFC 8615 reserves that path prefix for machine-readable metadata so tools can guess where to look. Your JWKS would work identically at /keys — you would just have to tell every integrator, forever. Follow the convention.jwks-rsa to fetch and cache it, Django uses PyJWT's PyJWKClient, Spring Security takes a single jwk-set-uri property and does the rest, and Go's go-jose reads the identical JSON. JWKS is an interoperable standard — which is exactly why every managed identity provider exposes this precise endpoint, and why the verifier you are about to write works unchanged against any of them.
kid, and checks signature, exp, iss and aud.app/auth.py, replacing the Module 07 version that read JWT_SECRET.kid you do not recognise turns up.# app/auth.py - the Module 07 file, rewritten. No JWT_SECRET anywhere.
import os, time
import httpx
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
AUTH_URL = os.environ["AUTH_SERVICE_URL"] # http://localhost:8001
ISSUER = os.environ["JWT_ISSUER"] # http://localhost:8001
AUDIENCE = os.environ["JWT_AUDIENCE"] # city-guide-api
JWKS_TTL = 600 # refetch at most every 10 minutes
MIN_REFETCH_GAP = 60 # never refetch more than once a minute on cache miss
_keys: dict[str, dict] = {}
_fetched_at = 0.0
oauth2 = OAuth2PasswordBearer(tokenUrl=f"{AUTH_URL}/token")
def _refresh() -> None:
global _keys, _fetched_at
resp = httpx.get(f"{AUTH_URL}/.well-known/jwks.json", timeout=3.0)
resp.raise_for_status()
_keys = {k["kid"]: k for k in resp.json()["keys"]}
_fetched_at = time.time()
def _key_for(kid: str) -> dict | None:
age = time.time() - _fetched_at
if not _keys or age > JWKS_TTL:
_refresh()
if kid not in _keys and age > MIN_REFETCH_GAP:
_refresh() # rotation may have just happened
return _keys.get(kid)
def current_user_id(token: str = Depends(oauth2)) -> int:
bad = HTTPException(status.HTTP_401_UNAUTHORIZED,
"Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"})
try:
kid = jwt.get_unverified_header(token).get("kid")
except JWTError:
raise bad
key = _key_for(kid) if kid else None
if key is None:
raise bad
try:
claims = jwt.decode(token, key, algorithms=["RS256"],
audience=AUDIENCE, issuer=ISSUER)
except JWTError:
raise bad
return int(claims["sub"])
Read algorithms=["RS256"] as a security control, not a configuration detail. It pins the algorithm the verifier will accept. Omit it, or derive it from the token's own alg header, and you reopen the classic JWT attack where an attacker sets alg to none or to HS256 — and in the HS256 case tricks a naive verifier into using your public key as an HMAC secret, which is a value they already have. Pin the list. Never trust the token to name its own algorithm.
kid. The TTL alone means a rotation is invisible to you for ten minutes. The kid miss alone means a key removed from JWKS lingers in memory forever. You need both.MIN_REFETCH_GAP, anyone can send a stream of tokens carrying invented kid values and make your API hammer your identity service. A cheap guard that turns an attack into a shrug.JWT_SECRET from .env and .env.example when you finish this section. Not comment it out — delete it. A leftover secret that nothing reads is a trap for the next person, who will assume it matters and copy it into the next service. The whole claim of this module is that the resource server holds no key material; make the file say so.
This is the payoff, and the reason kid exists at all. If you only read one section twice, read this one.
auth_service/keys.py, and which private key make_token signs with.The mechanism that makes this possible is small: JWKS is a list, and each token names the key that signed it. So the set of valid keys and the set of signing keys are allowed to differ for a while, and that window is where a rotation lives.
openssl genrsa -out keys/private-key-2.pem 2048 then openssl rsa -in keys/private-key-2.pem -pubout -out keys/public-key-2.pem. Nothing is live yet.key-2 to PUBLIC_KEY_PATHS and restart the identity service. JWKS now returns two entries. Every verifier that refetches learns key-2. Still nothing signs with it — you are pre-loading trust.key-2 tokens for exactly as long as their cache is stale. The unknown-kid refresh saves you, but do not lean on the rescue path.key-2. Point AUTH_KID and AUTH_PRIVATE_KEY_PATH at the new key and restart. New logins get kid: key-2. Every token minted before this instant still carries kid: key-1, still verifies, and nobody is logged out.key-1.key-1 from JWKS and delete the private key. The rotation is complete. Zero requests failed, zero users re-authenticated, and no maintenance window was booked.Now try to write those six steps for HS256. You cannot. There is one secret, it is used for both signing and verifying, and every service reads the same value. Change it and every outstanding token becomes invalid at that instant, everywhere; leave it and you have not rotated. The only symmetric options are "log everyone out" or "run two secrets and try both on every request" — and that second one is you reinventing kid, badly, without a way to publish the list. Rotation is where the asymmetric design stops being theoretical and starts being the reason you did this.
Do not sign up for anything to complete this module. Read this section instead — it is the whole reason the module exists.
jwk-set-uri and nothing else.iss and aud values. That is the integration.Here is an honest list of what you did not build today, because these are real and they are the product:
And now the counterpoint this module was built to earn. Every item above sits around the token. The token itself — a JWT with iss, aud, sub, iat, exp and a kid in the header, RS256-signed, verified against a public key fetched from /.well-known/jwks.json and cached — is exactly what you just built, verified in exactly the way you just verified it. Swapping your auth_service for a hosted provider changes three environment variables in the City Guide API. Not a line of app/auth.py.
That is why this module is here. "We use managed auth" is now a sentence you can finish. You know what their endpoint returns, why kid is in the header, what happens when they rotate a key at 2am, and what your service is trusting when it accepts their token. When the login integration breaks in a job, you will be debugging a system whose shape you have already held in your hands — and when someone claims their provider does something magical, you will know which part is the magic and which part is four hundred lines of Python you have written.
auth_service on port 8001 issues RS256 tokens carrying a kid and serves its public key at /.well-known/jwks.json. The City Guide API on port 8000 accepts those tokens with no key material and no JWT_SECRET anywhere in its configuration. You perform a full key rotation end to end and nobody gets logged out.
keys/*private* to .gitignore, then generate private-key-1.pem and public-key-1.pem with openssl. Confirm with git check-ignore -v keys/private-key-1.pem.auth_service/ with keys.py and main.py. Move register and login out of app/. Run it with uvicorn auth_service.main:api --port 8001.app/auth.py to verify against JWKS with a TTL cache. Delete JWT_SECRET from .env and .env.example, and grep the whole project to prove nothing reads it.kid.key-2. Before step 6, confirm a token minted under key-1 still works.kid; present a token whose aud is analytics-api.Run these in order with both services up. Every line is pass or fail on its own — you never need anyone to tell you whether this module is finished.
$ curl -s http://localhost:8001/.well-known/jwks.json | python -m json.tool
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-1",
"n": "qC_wnNbgtz-Aek0wBMwMnqhyWil9…yHFSKP4GDQ",
"e": "AQAB"
}
]
}
# your n differs from mine - it is your key. It is 342 characters long,
# and e is "AQAB" on essentially every RSA key you will ever see.
$ TOKEN=$(curl -s -X POST http://localhost:8001/token \
-d "username=ada@example.com&password=hunter2hunter2" \
| python -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
$ echo ${#TOKEN}
530 # roughly - RS256 tokens are ~3x an HS256 one
$ echo $TOKEN | cut -d. -f1 | python -c "import sys,base64; \
s=sys.stdin.read().strip(); \
print(base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)).decode())"
{"alg":"RS256","kid":"key-1","typ":"JWT"}
# there is the kid. This is what the verifier reads before it trusts anything.
$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/me
{"id":1,"email":"ada@example.com"} # 8000 verified a token it has no key for
$ grep -rn "JWT_SECRET" app/ auth_service/ .env .env.example
# empty output = no shared secret left
# --- the deliberate breaks -------------------------------------------------
# 1. identity service down, token already known to the cache
$ pkill -f "auth_service.main"
$ curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" http://localhost:8000/me
200 # STILL WORKS. Understand why before moving on.
$ uvicorn auth_service.main:api --port 8001 & # bring it back
# 2. a kid nobody has ever published
$ BADKID=$(python -c "
from jose import jwt; import time
print(jwt.encode({'sub':'1','iss':'http://localhost:8001','aud':'city-guide-api',
'exp':int(time.time())+900},
open('keys/private-key-1.pem').read(),
algorithm='RS256', headers={'kid':'key-99'}))")
$ curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $BADKID" http://localhost:8000/me
401 # correctly signed, unknown key name, refused
# 3. right signer, wrong audience
$ WRONGAUD=$(python -c "
from jose import jwt; import time
print(jwt.encode({'sub':'1','iss':'http://localhost:8001','aud':'analytics-api',
'exp':int(time.time())+900},
open('keys/private-key-1.pem').read(),
algorithm='RS256', headers={'kid':'key-1'}))")
$ curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $WRONGAUD" http://localhost:8000/me
401 # signature is perfect. It was not minted for you.
# --- after the rotation ----------------------------------------------------
$ curl -s http://localhost:8001/.well-known/jwks.json \
| python -c "import sys,json; print([k['kid'] for k in json.load(sys.stdin)['keys']])"
['key-1', 'key-2'] # both published during the overlap window
$ curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" http://localhost:8000/me
200 # the old key-1 token is still fine. Zero downtime.
audience= to jwt.decode, and your API will accept any token your identity provider ever minted for anyone — including tokens you deliberately handed to a service you do not fully trust. It is the quietest of the three failures and the most dangerous.
Write your answers in notes/module-08.md before you open any of them. Where your answer and mine disagree, work out which is right — the disagreement is the useful part.
1. The City Guide API accepts authenticated requests and holds no secret of any kind. Why is that safe?
Because verifying an RSA signature and producing one are different operations needing different keys. The API holds only the public key — enough to confirm that a token's signature was produced by the matching private key, and useless for producing a new one. The security property is the asymmetry itself, not secrecy of what the API holds. This also inverts the usual instinct: the API is safer for holding nothing, because breaching it yields an attacker no ability they did not already have by fetching a public URL. Under HS256 the same breach hands them a token mint.
2. What is kid for, and what specifically breaks if you remove it?
kid names which key signed this token, so a verifier holding several public keys knows which to try. Remove it and verification still works while exactly one key exists — try each key in turn until one succeeds. It breaks the moment there are two, because "try them all" is now the only strategy, which is slower, and worse, it silently accepts a token signed by a key you were in the middle of retiring. You also lose the unknown-kid cache refresh, which is what lets a verifier notice a rotation before its TTL expires. kid is not about security; it is what makes rotation operable.
3. Write out why zero-downtime rotation is possible under RS256 and impossible under HS256.
Under RS256 the signing key and the verifying keys are different objects, and the verifying set is a published list. So you can add a key to the list, wait for everyone to fetch it, switch signing to it, wait one token lifetime, then remove the old one — at every instant, every outstanding token names a key that is still in the list. Under HS256 there is one value doing both jobs and no list to publish. Changing it invalidates every outstanding token at that instant, in every service simultaneously, because the same value that stopped signing also stopped verifying. Your only symmetric options are logging everyone out or hand-rolling a multi-secret try-each scheme — which is kid and JWKS, reinvented without the distribution mechanism that makes them work.
4. An attacker downloads your entire JWKS. What can they now do?
Verify tokens. That is the complete list. They cannot forge one, because deriving the private key from the public one means factoring a 2048-bit modulus, which nobody can do. Publishing it is the intended use — that endpoint is deliberately unauthenticated. The one genuine risk is not the key but your verifier: if it reads alg from the token instead of pinning algorithms=["RS256"], an attacker can set alg: HS256 and sign with your public key as the HMAC secret, and a naive library will happily check it. The exposure there is your code accepting the wrong algorithm, not the key being public.
5. With the identity service stopped, the API still accepted a token. Is that a bug?
No — it is the main operational benefit of the design. Verification is local computation against a cached public key, so the identity service is on the login path but not on the request path. It can be down for maintenance and every already-logged-in user keeps working; only new logins fail. The honest cost is the other half of the same property: because nothing is checked against a database per request, you cannot revoke a token before its exp. Disabling an account stops new tokens, not existing ones. That is exactly why exp is fifteen minutes, and it is the trade you are accepting when you choose stateless tokens over server-side sessions.
6. Your team decides to buy a managed identity provider. What changes in the City Guide API?
Three environment variables: the JWKS URL, the expected iss, and the expected aud. app/auth.py does not change — the fetch, the kid lookup, the cache and the claim checks are identical, because JWKS is a standard and their endpoint returns the same shape yours does. What you are buying sits elsewhere: hosted login screens, SSO connectors, MFA, breach detection, login rate limiting, compliance attestations, and someone else carrying the pager for the most attacked endpoint you own. Those are real and expensive to build. The token, and your verification of it, is the part you already have.