Hash a password, sign a token, verify it on every request — by hand, with nothing managed and nothing hidden. Then break it four ways on purpose.
Two words that look alike, abbreviate almost identically, and mean completely different things. Getting them straight now saves you from a codebase where every failure is a 403 and nobody knows why.
Your City Guide API has a users table from Module 05 and working places endpoints from Module 06. Right now anyone who can reach the port can write to your database. That is the hole this module closes. Two requests make the distinction concrete — same API, entirely different reasons for refusing:
# A - no credential at all
POST /places (no Authorization header)
→ 401 {"detail": "Not authenticated"}
# "I do not know who you are. Come back with a token."
# B - a valid token, but someone else's place
DELETE /places/12 Authorization: Bearer <valid token for user 7>
→ 403 {"detail": "You do not own this place"}
# "I know exactly who you are, user 7. Place 12 belongs to user 3."
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| Means | Missing, malformed, expired or invalid credential | Valid credential, insufficient permission |
| Server knows who you are? | No | Yes |
| Would a fresh login help? | Yes | No — you are simply not allowed |
| Required response header | WWW-Authenticate: Bearer | None |
WWW-Authenticate: Bearer, telling the client what credential to come back with. A 401 without that header is an incomplete answer, and clients that auto-refresh tokens key off it.One job in this section, and it is absolute: a stolen dump of your users table must be useless to whoever stole it.
password_hash column of your users table. The plaintext exists in memory for milliseconds and is never written anywhere.passlib with bcrypt: pwd_context.hash(password) and pwd_context.verify(attempt, stored_hash).Hashing is not encryption. Encryption is reversible on purpose — a key turns the ciphertext back. A hash has no key and no door. You never need to recover a password; you only need to know whether the one someone just typed produces the same hash. "We can email you your password" is a confession that a site stores passwords recoverably.
Now the counter-intuitive part. MD5 and SHA-256 are hashes, and they are the wrong tool here — not because they are broken, but because they are fast. SHA-256 is designed to hash a gigabyte quickly, so a consumer GPU computes billions per second. An attacker holding your table does not reverse anything; they guess passwords and hash each guess. Speed is the attacker's weapon, so the defence is a hash that is deliberately, tunably slow — a GPU does roughly a billion SHA-256 guesses a second and about thirty bcrypt ones per core, which is the difference between a dictionary falling in seconds and falling in years. And a salt — random bytes mixed in before hashing, unique per user. It defeats two things at once: precomputed rainbow tables become useless, because your salt was not part of the attacker's computation; and without it, two users who both chose password123 would have identical hashes, so cracking one cracks both, and the visible repetition tells an attacker which accounts to target first.
# app/security.py - pip install "passlib[bcrypt]"
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, stored: str) -> bool:
return pwd_context.verify(plain, stored) # constant-time comparison
Call hash_password("hunter2") twice and you get two different strings, because a fresh salt goes into each. Here is what one looks like, and it is worth taking apart:
$2b$12$eIX/dQ8SBu.gKZ7YHRmL8ep0v1sHkYbNqW3lJ9fTtQ4mCxRvZuA0i
| | | |
| | | +-- 31 chars: the hash itself
| | +-- 22 chars: the salt, generated randomly for THIS user
| +-- cost factor 12 = 2^12 = 4096 rounds of key expansion
+-- algorithm identifier: bcrypt, revision 2b
The salt and the cost live inside the hash string. That design is what makes this practical: no extra columns, and verify() reads the salt and cost back out of the stored value so it can recompute the same hash from the attempt. It also lets you raise the cost as hardware improves — old hashes keep verifying at their old cost, and deprecated="auto" flags them for rehashing on the next successful login.
verify() is constant-time, and Argon2 is a drop-in upgrade. A naive == would leak through how long the comparison took — a timing attack — and passlib closes that. Switching to schemes=["argon2"] changes nothing else in this module.AttributeError: module 'bcrypt' has no attribute '__about__', nothing is broken. passlib 1.7.4 reads bcrypt's version from an attribute that bcrypt 4.1 removed, and logs a trapped error at import. Hashing still works. Pin bcrypt==4.0.1 if the noise bothers you.
app/routers/auth.py, using the get_db dependency from Module 05 and the response-model pattern from Module 06.# app/schemas/auth.py
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=72) # 72 = bcrypt's limit
class UserOut(BaseModel): # note: no password field. Ever.
id: int
email: EmailStr
model_config = {"from_attributes": True}
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer" # LoginIn is RegisterIn without Field
UserOut having no password field is not an oversight, it is the mechanism. Because Module 06 taught you to declare response_model=UserOut, FastAPI filters the ORM object down to exactly those fields on the way out — so even if a later refactor returns the whole User row, the hash cannot escape through this endpoint.
# app/routers/auth.py
from app.db import get_db # Module 05
from app.security import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
def register(body: RegisterIn, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=409, detail="Email already registered")
user = User(email=body.email, password_hash=hash_password(body.password))
db.add(user); db.commit(); db.refresh(user)
return user
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == body.email).first()
# ONE error for BOTH failure modes. Read the note below before "improving" it.
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"})
return TokenOut(access_token=create_access_token(subject=str(user.id)))
That single shared error message is the most important line in this section. The obvious, friendly design returns "no account with that email" and "wrong password" separately. Do not. An attacker with ten million email addresses then feeds each to /login with a junk password and sorts your users from your non-users — user enumeration, which turns login into a membership oracle. For a dating site, a medical service or a job board, knowing an address has an account is the leak.
/register returning 409 "already registered" leaks the same fact. Avoiding it fully needs an email-confirmation flow that always says "check your inbox". Ship the 409 for now — but know why it is there.Referer header of the next request.users.email in an Alembic migration. The if check races two simultaneous registrations; the database constraint is what actually prevents duplicates.A JWT is not magic and it is not encrypted. It is three chunks of base64 with dots between them, and once you have read one by hand it stops being mysterious forever.
Authorization: Bearer <token> header of every request to a protected endpoint.jwt.encode(payload, secret, algorithm="HS256") to sign, jwt.decode(token, secret, algorithms=["HS256"]) to verify.Here is a real one, signed with the JWT_SECRET from your Module 00 .env. Decode the first two segments — no secret required, which is the entire point of this section:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3IiwiZXhwIjoxNzY3MjI1NjAwLCJpYXQiOjE3NjcxMzkyMDB9.7GuDUY6vZQ_NoI6vK4mA_ECW-hHVWRxFrc9RQ3qiepw
<----------- header ---------------> <---------------- payload -----------------> <------------ signature ------------->
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "7", "exp": 1767225600, "iat": 1767139200}
| | |
| | +-- issued at: 2025-12-31 00:00:00 UTC
| +-- expires at: 2026-01-01 00:00:00 UTC
+-- subject: WHO this token is about - user id 7 in your users table
Those three claim names are registered in RFC 7519, which is why every JWT library everywhere understands them. sub is the identity the token asserts — use the database id, not the email, because emails change. exp is a Unix timestamp after which the token is invalid, and libraries enforce it for you. iat records when it was minted, which lets you invalidate everything issued before a chosen moment.
Why the server stores nothing. The signature is HMAC-SHA256(header + "." + payload, secret). When a token comes back, the server recomputes that HMAC over the segments it was handed and compares. A match means the payload is byte-for-byte what it originally signed, because producing a matching signature requires the secret. No lookup, no session table, no database round-trip — verification is pure arithmetic, which is why any number of servers can verify independently.
And now the price. Because the server stores nothing, it cannot un-issue anything. Change a user's password, disable the account, fire them — a token already in the wild keeps verifying until exp passes, because verification never consults your database about validity. There is no session row to delete. That single fact is why access tokens are minutes long rather than weeks, and it is the trade-off the rest of this module is organised around.
# app/security.py (continued) - pip install pyjwt
import os, jwt
from datetime import datetime, timedelta, timezone
JWT_SECRET = os.environ["JWT_SECRET"] # from .env - Module 00
JWT_ALGORITHM, ACCESS_TOKEN_MINUTES = "HS256", 15 # 15 min: it cannot be revoked
def create_access_token(subject: str) -> str:
now = datetime.now(timezone.utc)
return jwt.encode({"sub": subject, "iat": now, "type": "access",
"exp": now + timedelta(minutes=ACCESS_TOKEN_MINUTES)},
JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
# algorithms= is NOT optional. See the warning below.
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
algorithms list. The token carries its own alg in the header, and a library that trusts that field can be attacked through it. Set alg to none and a naive verifier accepts an unsigned token. Worse, against a service that signs with RSA, an attacker flips alg to HS256 and signs using the public key as if it were an HMAC secret — algorithm confusion, and it has broken real systems. pyjwt demands the list precisely so you cannot make this mistake by accident. Never compute it from the incoming header.
Authorization header into a User row, or raises 401 trying.app/deps.py, next to the get_db dependency from Module 05.current_user: User = Depends(get_current_user) in the handler signature.This is where FastAPI's dependency injection earns its keep. A dependency is a function whose result is computed before your handler runs; declaring it as a parameter is how you ask for that result. And dependencies compose — get_current_user itself declares Depends(get_db), so FastAPI resolves the database session first, hands it in, and both are ready by the time your handler body executes.
# app/deps.py
CREDENTIALS_ERROR = HTTPException(
status_code=401, detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}) # required on every 401
def get_current_user(
authorization: str | None = Header(default=None),
db: Session = Depends(get_db), # Module 05
) -> User:
if authorization is None or not authorization.startswith("Bearer "):
raise CREDENTIALS_ERROR
try:
payload = decode_token(authorization.removeprefix("Bearer ").strip())
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token has expired",
headers={"WWW-Authenticate": "Bearer"})
except jwt.InvalidTokenError: # bad signature, malformed, wrong alg
raise CREDENTIALS_ERROR
if payload.get("sub") is None or payload.get("type") != "access":
raise CREDENTIALS_ERROR
user = db.get(User, int(payload["sub"])) # deleted since it was issued?
if user is None:
raise CREDENTIALS_ERROR
return user
That last lookup is not redundant. The token already carries the user id, and it is signed, so you could skip the query. Do not. The row is how you notice the account was deleted or disabled after the token was minted — it is the one place a stateless token gets a sanity check against reality.
Now protect the endpoint. One extra parameter, and the handler that let anyone write to your database no longer does:
# app/routers/places.py
@router.post("/places", response_model=PlaceOut, status_code=201)
def create_place(body: PlaceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)): # <- the change
place = Place(**body.model_dump(), owner_id=current_user.id)
db.add(place); db.commit(); db.refresh(place)
return place
create_place runs, current_user is a real row — there is no if user is None branch to forget, because the request never reached you.owner_id unlocks. Recording who created a place is what makes a 403 possible later: if place.owner_id != current_user.id is your first authorisation rule, and it lives in the handler, not the dependency.user_id field is an invitation to act as anyone. Identity comes from the token and nowhere else — this is the most common auth bug in real code.GET /places is a public city guide; only writes need a token. Protecting everything reflexively is a design decision, not a default.HTTPBearer from fastapi.security does the header parsing for you and adds an Authorize button to /docs. Swap to it once you have written the manual version and know what it replaced.This is the heart of the module. Auth code that has only ever been tested by succeeding is auth code you do not understand. Run every one of these and read the actual error.
get_current_user.curl.Start from a real token and keep the repeated parts in variables:
$ J="Content-Type: application/json"
$ B='{"name":"Blue Cafe","neighbourhood":"Southbank","rating":5}'
$ TOKEN=$(curl -s -X POST localhost:8000/auth/login -H "$J" \
-d '{"email":"you@example.com","password":"hunter2hunter2"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
1. Tamper with the payload. Change one character of the token — turn a 7 into an 8 in the middle segment, or mangle the last byte:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
-H "$J" -d "$B" -H "Authorization: Bearer ${TOKEN%?}X"
401
Client sees: 401 with {"detail":"Could not validate credentials"}. Server raises: jwt.exceptions.InvalidSignatureError, a subclass of InvalidTokenError. The signature was computed over the original bytes; you changed the bytes; the recomputed HMAC no longer matches. Note what did not happen — the server looked nothing up to know the token was forged.
2. Let a token expire. Mint one with a five-second life, then use it twice:
$ SHORT=$(python3 -c "
import os, jwt; from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
print(jwt.encode({'sub':'7','iat':now,'exp':now+timedelta(seconds=5),'type':'access'},
os.environ['JWT_SECRET'], algorithm='HS256'))")
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
-H "$J" -d "$B" -H "Authorization: Bearer $SHORT"
201 # works - we are inside the five seconds
# count to ten, then run the exact same command again
401
Client sees: 401 with {"detail":"Token has expired"}. Server raises: jwt.exceptions.ExpiredSignatureError. The signature is still perfectly valid; nothing was tampered with. pyjwt compared exp against the current clock and rejected it on that basis alone. This is the only one of the four where the token is authentic and still refused — sit with that. Expiry is enforced by a verifier reading a number, not by anything remembering the token exists.
3. Send no header at all.
$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/places -H "$J" -d "$B"
{"detail":"Could not validate credentials"}
401
Client sees: 401, plus the WWW-Authenticate: Bearer response header telling it what to come back with — confirm with curl -i. Server raises: nothing from pyjwt. Your own first if catches it before any decoding is attempted. A 401 without that header is the incomplete answer from the first section.
4. Sign with a different secret. The impersonation attempt — an attacker who knows your token format exactly, and everything except the secret:
$ FORGED=$(python3 -c "
import jwt; from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
print(jwt.encode({'sub':'1','iat':now,'exp':now+timedelta(hours=1),'type':'access'},
'i-guessed-your-secret', algorithm='HS256'))")
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
-H "$J" -d "$B" -H "Authorization: Bearer $FORGED"
401
Client sees: 401, identical to experiment 1 — deliberately so, because a distinct message would tell an attacker whether the structure was right. Server raises: InvalidSignatureError again. Decode that forged payload and you will see a perfectly well-formed {"sub":"1",...}: correct in every respect except the one that matters. Now the uncomfortable follow-up — replace i-guessed-your-secret with your real JWT_SECRET and the same request returns 201, creating a place as user 1. Your entire auth system is that one string. That is why it lives in .env, why .env is git-ignored, and why it must be long and random: python3 -c "import secrets; print(secrets.token_urlsafe(32))".
jsonwebtoken — jwt.sign(payload, secret, {expiresIn: '15m'}) plus a middleware calling jwt.verify and attaching req.user. Django ships authentication and PBKDF2 password hashing in the box, with djangorestframework-simplejwt for tokens. Rails gives you has_secure_password, which is bcrypt behind one macro, and Devise for the full flow. The packaging differs completely; the primitives do not. Every one of them hashes on register, verifies on login, signs a token, and re-verifies that signature on each request — and every one can be broken by these same four experiments.
Fifteen-minute tokens are the correct answer to "cannot be revoked" and an obviously terrible user experience. Refresh tokens are how both stay true at once.
refresh_tokens table — hashed, like a password./auth/refresh after a request comes back 401 "expired", then retries the original request.The asymmetry is the whole design. An access token is stateless and unrevokable, so it must be short. A refresh token is checked against a database row, which means you can revoke it — delete the row and it is dead immediately. You pay one query, but once every fifteen minutes rather than on every request. Statelessness where it is hot, statefulness where you need control.
# refresh_tokens: id, user_id, token_hash, expires_at, revoked_at
# (add it with an Alembic migration - Module 05)
@router.post("/refresh", response_model=TokenOut)
def refresh(body: RefreshIn, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc)
row = db.query(RefreshToken).filter(
RefreshToken.token_hash == sha256_hex(body.refresh_token)).first()
if row is None or row.revoked_at is not None or row.expires_at < now:
raise HTTPException(401, "Invalid refresh token")
row.revoked_at = now # rotation: single use only
new_refresh = issue_refresh_token(db, row.user_id)
db.commit()
return TokenOut(access_token=create_access_token(str(row.user_id)),
refresh_token=new_refresh)
Rotation, in outline. Each refresh consumes the old token and issues a new one. If an already-revoked refresh token is ever presented again, two parties hold it — the real client and a thief — so the correct response is to revoke every refresh token for that user and force a re-login. You do not need that detection today; you do need to see why single-use rotation is what makes it possible.
localStorage is readable by any script injected into your page; an HttpOnly cookie is not, but brings CSRF concerns of its own. There is no free option, only an informed one./login — otherwise your slow hash is the only thing between an attacker and a million guesses — account lockout or progressive delays after repeated failures, a password reset flow that does not itself leak account existence, email verification, and a deliberate decision about token storage on the client. The multi-service part, where a shared HS256 secret stops being acceptable, is Module 08. What you have built here is correct in its primitives and incomplete at its edges; knowing exactly which edges is the point.
POST /places refuses anonymous requests with a 401 and accepts authenticated ones with a 201, backed by /auth/register, /auth/login and /auth/refresh — with all four break experiments run and their results written down.
password_hash to the User model plus an Alembic migration with a unique index on email. Install passlib[bcrypt] and pyjwt; generate a real JWT_SECRET into .env and add the key to .env.example.app/security.py: hash_password, verify_password, create_access_token, decode_token. Hash the same password twice and confirm the two strings differ.POST /auth/register (201, no hash in the body) and POST /auth/login (returns a token, and the same 401 for unknown email and for wrong password).get_current_user in app/deps.py and add current_user: User = Depends(get_current_user) to POST /places. Store owner_id on the place.notes/module-07.md.refresh_tokens table and POST /auth/refresh with single-use rotation, then confirm a used refresh token fails on its second use.Every one of these is objectively pass or fail. Run them in order against a running server; if a status code differs, the module is not finished.
$ J="Content-Type: application/json"
$ B='{"name":"Blue Cafe","neighbourhood":"Southbank","rating":5}'
$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/register -H "$J" \
-d '{"email":"you@example.com","password":"hunter2hunter2"}'
{"id":7,"email":"you@example.com"}
201 # 201, and NO password field in the body
$ docker compose exec db psql -U cityguide -d cityguide \
-c "SELECT password_hash FROM users WHERE email='you@example.com';"
$2b$12$eIX/dQ8SBu.gKZ7YHRmL8e... # starts with $2b$12$ - never the plaintext
$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/login -H "$J" \
-d '{"email":"nobody@example.com","password":"whatever"}'
{"detail":"Incorrect email or password"}
401 # unknown email
$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/login -H "$J" \
-d '{"email":"you@example.com","password":"wrongwrongwrong"}'
{"detail":"Incorrect email or password"}
401 # wrong password - byte-identical to above
$ TOKEN=$(curl -s -X POST localhost:8000/auth/login -H "$J" \
-d '{"email":"you@example.com","password":"hunter2hunter2"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# READ YOUR OWN TOKEN - no secret, no library, no permission
$ python3 -c "import sys,base64,json; p=sys.argv[1].split('.')[1]; \
print(json.loads(base64.urlsafe_b64decode(p + '=' * (-len(p) % 4))))" "$TOKEN"
{'sub': '7', 'iat': 1767139200, 'type': 'access', 'exp': 1767140100}
# exp is iat + 900s. It is all readable.
$ C='curl -s -o /dev/null -w %{http_code}\n -X POST localhost:8000/places'
# $C prints only the status code
$ $C -H "$J" -d "$B"
401 # no Authorization header
$ curl -si -X POST localhost:8000/places -d '{}' | grep -i www-authenticate
WWW-Authenticate: Bearer # this header MUST be present on a 401
$ $C -H "$J" -d "$B" -H "Authorization: Bearer $TOKEN"
201 # the same request, with a token
$ $C -H "$J" -d "$B" -H "Authorization: Bearer ${TOKEN%?}X"
401 # one character changed - signature fails
$ $C -H "$J" -d "$B" -H "Authorization: Bearer $SHORT"
401 # $SHORT from Break it, experiment 2
Write your answers in notes/module-07.md before you open any of these. Where your answer and the given one disagree, work out which is right — that gap is the actual lesson.
1. What is inside a JWT? Name every part and every claim, and say what each one does.
Three base64url segments joined by dots. The header declares the signing algorithm (alg: HS256) and type. The payload holds claims: sub is the subject — who the token is about, here the user's database id; exp is a Unix timestamp after which verifiers must reject it; iat records when it was issued, letting you invalidate everything minted before a chosen moment. The signature is HMAC-SHA256(header + "." + payload, secret). Other registered claims you will meet: iss (issuer), aud (audience — which service the token is for, which starts mattering in Module 08), and jti (a unique token id, the basis of any denylist). Header and payload are encoded, not encrypted, and readable by anyone.
2. Why can the server verify a token without having stored it anywhere?
Because verification is recomputation, not lookup. The server takes the header and payload segments exactly as received, runs the same HMAC over them with the same secret, and compares the result to the signature segment. A match proves the payload is byte-for-byte what this server signed, since forging a match would require the secret. Nothing needs remembering, which is why any number of servers sharing the secret verify independently with no session store and no database round-trip. The cost is symmetrical to the benefit: a server that remembers nothing about a token also cannot forget one, so there is no revocation. Storing tokens would buy back revocation and cost you exactly the property that made JWTs attractive.
3. Your access token leaks — a proxy log, a screenshot, a browser extension. What can the attacker do, and for how long?
Everything that user can do, until exp passes, and nothing you do stops it. Changing the password does not help: verification never consults the password. Deleting the session does not help: there is no session. The mitigations are structural. Short expiry bounds the damage to fifteen minutes. Deleting the user's refresh tokens stops them obtaining a new access token but leaves the current one live. Rotating JWT_SECRET does work — it invalidates the leaked token instantly — but logs out every user on the platform, so it is an emergency lever, not routine. True immediate revocation requires a denylist of jti values checked per request, at which point you have given back the statelessness you came for.
4. Why must "no such user" and "wrong password" return the identical error?
Because a different response turns login into a membership oracle. An attacker feeds ten million addresses to /login with junk passwords and sorts registered from unregistered by the reply — user enumeration. For many services the mere fact that an address has an account is the sensitive information: a dating site, a health service, a job board, a support forum for a stigmatised condition. Even where it is not, a confirmed list of real accounts is exactly the input a credential-stuffing attack needs. Note that the body is only half of it — if the missing-user branch returns in a microsecond while the wrong-password branch spends 250ms in bcrypt, the timing rebuilds the same oracle. Hash a dummy password on the missing-user path.
5. SHA-256 is a strong, unbroken cryptographic hash. Why is it the wrong tool for passwords?
Because its design goal is speed, and speed is what the attacker needs. Nobody reverses a hash; they guess passwords and hash each guess against your stolen table. A consumer GPU computes billions of SHA-256 hashes per second, so a dictionary of every password anyone has ever used falls in minutes. bcrypt at cost 12 runs 4096 rounds of key expansion per hash, cutting an attacker to tens of guesses per second per core, and the cost is tunable upward as hardware improves. Salting is a separate defence solving a separate problem — it kills precomputed rainbow tables and stops identical passwords producing identical hashes — and salting a fast hash leaves it fast. You need both, which is why bcrypt and Argon2 do both by construction.
6. Why does get_current_user query the database at all, when the signed token already contains the user id?
Because the token is a statement about the past — "at 09:00 this was user 7" — and the row is the present. Between issuing and presenting, the account may have been deleted, suspended, had its email changed, or had its role downgraded. The token cannot know any of that; it was signed before it happened. The lookup is the one moment a stateless system checks itself against reality, and it is what enables soft revocation: add an is_active column, check it in the dependency, and disabling an account takes effect on the next request rather than in fifteen minutes. The cost is one indexed primary-key read, close to free. Skipping it is a real optimisation with a usually unacceptable price.