Skip to content
MCP, From Scratch

Auth, Understood

Phase 2TypeBuildLanguagePythonTime~17 min readPrereqDesigning the Data

Last post ended with five tables and an Alice you could find by UUID. Nobody could log in as her. This post fixes that with the least code that is still honest: register, login, a token, and a way for every future route to ask “who is calling?”. The design doc set the bar for the phase at one sentence — can get a token and hit /auth/me — and we’ll hit exactly that, then stop.

What a login is, mechanically

Strip away the framework and a login is a small ceremony between the browser and the server. Here is the whole of it, top to bottom:

POST /auth/register { email, full_name, password } → 201, a user row with a bcrypt hash
POST /auth/login { email, password } → verify the hash → 200 { access_token }
every later request carries Authorization: Bearer <token>
current_user decodes the token, loads the row, refuses anything off → a User, or 401
the route body runs, already knowing who is calling

Two words in that diagram need defining before the code makes sense, because the whole design rests on them.

A bcrypt hash is what we store instead of a password. It is a one-way function: given a password you can compute the hash, and given a hash and a candidate password you can check whether they match — but you cannot get the password back out. bcrypt is also slow on purpose, tens of milliseconds per hash, so that guessing a billion passwords against a leaked table takes years instead of hours.

A JSON Web Token (JWT) is three pieces of base64 joined by dots: a header, a JSON payload of “claims”, and a signature over the first two. The important thing, the one most people get wrong on first contact: the claims are not encrypted. Anyone holding the token can read them. What the signature gives you is that nobody without JWT_SECRET could have produced them. So the backend can hand a token to a browser, get it back twelve hours later, and trust the user id inside without asking the database whether the token is genuine.

You paste a TaskFlow access token into a JWT decoder website. Which statement is true?

Passwords: the half we never store

All the cryptography in the backend lives in one file, . Its first half is passwords:

fastapi-backend/app/security.py
from passlib.context import CryptContext

# One CryptContext for the whole process. `schemes` is the list of algorithms
# we accept; `deprecated="auto"` means "anything but the first one is legacy
# and should be re-hashed on next login" — irrelevant today with one scheme,
# but it's the setting that makes a future migration to argon2 a one-line
# change here and nowhere else.
#
# The seed script uses this same context so demo hashes and login hashes are
# produced by exactly one code path.
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(plain: str) -> str:
    """Return a bcrypt hash suitable for ``users.password_hash``."""
    return _pwd_context.hash(plain)


def verify_password(plain: str, hashed: str) -> bool:
    """True if ``plain`` is the password behind ``hashed``.

    passlib does the comparison in constant time, so an attacker can't learn
    how many leading bytes matched from how long the call took.
    """
    return _pwd_context.verify(plain, hashed)
  • CryptContext is passlib’s wrapper around one or more hashing algorithms. We name one, bcrypt. The deprecated="auto" setting does nothing today, but it is what would make “move everyone to argon2” a one-line change in one file later.
  • hash_password is called exactly twice in the codebase: by /auth/register, and by the seed script. That second caller matters. Phase 1’s seed had its own CryptContext. If the two ever drifted — different rounds, different scheme — the demo password would stop logging in and nothing would say why. So the seed now imports this function instead, and there is one code path that produces hashes.
  • verify_password compares in constant time. A naive string comparison returns early at the first mismatched byte, and the timing difference — nanoseconds, but measurable over many requests — tells an attacker how many leading bytes they got right. passlib doesn’t leak that.

Tokens: sign, then decode

The second half of the same file is the JWT. Two functions, one exception:

fastapi-backend/app/security.py
import jwt  # PyJWT


class TokenError(Exception):
    """Raised when a token can't be trusted, for any reason.

    Callers (``deps.current_user``) turn this into one uniform 401. We collapse
    PyJWT's several exception types into one on purpose: a client gains
    nothing from knowing *why* its token was rejected, and an attacker gains a
    little.
    """


def create_access_token(user_id: uuid.UUID, email: str, *, expires_in: timedelta | None = None) -> str:
    settings = get_settings()
    now = datetime.now(UTC)
    lifetime = expires_in if expires_in is not None else timedelta(minutes=settings.jwt_expire_minutes)
    claims = {
        # "sub" (subject) is the standard claim for "who this token is about".
        # It's a string in the JWT spec, so we stringify the UUID.
        "sub": str(user_id),
        # Not needed to identify the user — `sub` does that — but handy for
        # anything that wants to show "logged in as alice@…" without a lookup.
        "email": email,
        # PyJWT converts datetimes to unix timestamps for these two.
        "iat": now,
        "exp": now + lifetime,
    }
    return jwt.encode(claims, settings.jwt_secret, algorithm=settings.jwt_algorithm)


def decode_access_token(token: str) -> dict:
    """Verify the signature and expiry, and return the claims.

    ``algorithms=[...]`` is not optional. A JWT's header says which algorithm
    signed it, and a classic attack sets that to ``"none"``. Passing an
    explicit allow-list tells PyJWT to refuse anything we didn't choose.
    """
    settings = get_settings()
    try:
        return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
    except jwt.PyJWTError as exc:  # expired, bad signature, malformed, wrong alg
        raise TokenError(str(exc)) from exc

Line by line:

  • import jwt is PyJWT, not python-jose. One dependency, two functions. The MCP server will use the same library to verify the same tokens in a later phase, so the two services agree by construction. python-jose wraps several crypto backends and has had unpatched CVEs — not worth a paragraph of explanation for a reader who just wants to understand tokens.
  • Four claims, exactly. sub is the standard name for “who this token is about” — the user’s UUID, as a string because the spec says so. email is a convenience for UIs. iat and exp are issued-at and expiry as unix seconds; PyJWT converts the datetimes for us. exp defaults to 720 minutes, twelve hours, one working day.
  • expires_in exists for tests. The only caller that passes it is a test that mints an already-expired token. Production code never does, which is why it is keyword-only — you can’t pass it by accident.
  • algorithms=[...] is the line to remember. A JWT’s header says which algorithm signed it, and a decoder that trusts the header can be handed a token whose header says "none" — no signature at all — and accept it. An explicit allow-list means PyJWT refuses anything we didn’t choose. Every JWT library has this parameter; every JWT tutorial should say why.
  • TokenError collapses every failure into one. Expired, tampered, malformed, wrong algorithm — PyJWT raises a different exception for each, and we fold them into one. The client’s only sensible reaction to any of them is “log in again”, so a more specific error would only help someone probing the server.

current_user: the dependency everything hangs off

FastAPI has a mechanism called dependencies, and it is worth one paragraph because the entire authorisation story of this series is built on it. A route declares a parameter like user: User = Depends(current_user). Before the route body runs, FastAPI calls current_user — resolving its parameters the same way — and either passes the return value in, or, if it raised an HTTPException, sends that response instead. The route body only ever sees a real, active user. From :

fastapi-backend/app/deps.py
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

# `auto_error=False` so a missing header reaches *our* code instead of
# FastAPI's default 403. A missing credential is a 401 ("who are you?"), not
# a 403 ("I know who you are and the answer is no") — and the two mean
# different things to the MCP server later, which maps them to different
# tool errors.
_bearer = HTTPBearer(auto_error=False)


def _unauthorized(detail: str) -> HTTPException:
    # RFC 6750 says a 401 for a bearer-protected resource should carry this
    # header. Browsers ignore it; well-behaved API clients don't.
    return HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail=detail,
        headers={"WWW-Authenticate": "Bearer"},
    )


def current_user(
    creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
    db: Session = Depends(get_db),
) -> User:
    if creds is None:
        raise _unauthorized("Not authenticated")

    try:
        claims = decode_access_token(creds.credentials)
    except TokenError:
        raise _unauthorized("Invalid or expired token")

    try:
        user_id = uuid.UUID(claims["sub"])
    except (KeyError, ValueError):
        raise _unauthorized("Invalid token subject")

    user = db.get(User, user_id)
    # Two cases folded into one: the user was deleted after the token was
    # issued, or an admin flipped `is_active` off. Either way the token is a
    # valid signature over an identity that may no longer act. This is the
    # one revocation-like check we get without a token blacklist.
    if user is None or not user.is_active:
        raise _unauthorized("User not found or inactive")

    return user
  • HTTPBearer is FastAPI’s helper for reading an Authorization: Bearer … header. Left alone, it answers a missing header with a 403. That is wrong by the standard: RFC 6750 says 401 with a WWW-Authenticate: Bearer header. It also matters later — the MCP server will turn 401 into “log in again” and 403 into “you’re not allowed”, and those are different tool errors. auto_error=False routes the missing case into our code so we can answer correctly.
  • Four ways to fail, one status. No header, a bad token, a sub that isn’t a UUID, a user that is gone or deactivated. Each gets a short reason for the developer reading a log, and all of them are 401. None of the reasons is a decision point for a client.
  • The database lookup is the point, not overhead. A signature check alone would say “this token was genuine when issued”. The db.get plus is_active check says “and the person behind it may still act”. Delete or deactivate the row, and every outstanding token stops working on the next request. It is the closest thing to revocation we get without a blacklist — and phase 3 needs the same lookup anyway to answer membership questions.

Three routes

holds register, login, and me. The shapes they accept and return live in , and two of those shapes carry decisions:

fastapi-backend/app/schemas/auth.py
class RegisterRequest(BaseModel):
    # EmailStr rejects things like "alice" or "a@b" before our code runs.
    # It needs the `email-validator` package — hence `pydantic[email]` in
    # pyproject.toml.
    email: EmailStr
    full_name: str = Field(min_length=1, max_length=200)
    # bcrypt ignores everything past 72 *bytes*. Rather than silently
    # truncate, we reject longer passwords so the user isn't surprised later
    # when "the first 72 characters" is what actually logs them in.
    password: str = Field(min_length=8, max_length=72)


class UserOut(BaseModel):
    """The public view of a user. No ``password_hash``, ever."""

    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    email: str
    full_name: str
    is_active: bool
    created_at: datetime
  • Passwords are capped at 72. bcrypt silently ignores bytes past the 72nd. Accept a 100-character password and you have actually stored the first 72 — the user will be surprised the day a typo in position 80 still logs them in. Refusing with a 422 is more honest than truncating.
  • UserOut is the only shape a user ever leaves the server in. It has no password_hash field. That is a stronger guarantee than “we remember not to include it” — there is no route that could leak it, because the response model doesn’t have the slot. from_attributes=True lets FastAPI build it straight from the ORM object.
  • Login takes JSON, not an OAuth2 form. FastAPI’s tutorial uses OAuth2PasswordRequestForm — form-encoded, fields named username and password — which buys you the “Authorize” button on the /docs page. But the frontend and the chat client will send JSON with an email field, because that is what the design doc says. Test like the real client beats a Swagger button. The cost: /docs can’t log you in interactively; paste a token into the bearer box instead.

The login handler is where the other decision lives:

fastapi-backend/app/routers/auth.py
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
    user = db.scalar(select(User).where(User.email == body.email.lower()))

    # One message for "no such user", "wrong password" and "deactivated".
    # Telling them apart would let anyone enumerate which emails have
    # accounts. (The timing still differs a little — a missing user skips the
    # bcrypt call. Padding that out with a dummy verify is a known refinement
    # we're not doing in a learning app; the brief says so.)
    if user is None or not verify_password(body.password, user.password_hash):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    return TokenResponse(access_token=create_access_token(user.id, user.email))
  • One 401 body for every failure. Unknown email, wrong password, deactivated account — all return {"detail": "Invalid email or password"}. A login form that says “no account with that email” is a free tool for finding out who has an account. A test later asserts the two responses are byte-identical.
  • The honest footnote. The timing still differs: an unknown email skips the bcrypt call and returns a few tens of milliseconds faster. The standard fix is a dummy verify on the miss path, two lines. We chose not to add them to a learning app, and the comment says so rather than pretending the leak isn’t there.
  • Register does not return a token. The frontend will do register → login, one extra round-trip, and in exchange exactly one route mints tokens. One place to reason about is worth a round-trip.

Two settings worth a sentence

gains the JWT values — JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_MINUTES — and turns on CORS. Two things there are not obvious.

fastapi-backend/app/config.py
# Browser origins allowed to call this API. Kept as the raw comma-separated
# string here; see cors_origin_list.
cors_origins: str = Field(
    default="http://localhost:5173,http://localhost:8100", alias="CORS_ORIGINS"
)

@property
def cors_origin_list(self) -> list[str]:
    """CORS_ORIGINS split into the list FastAPI's middleware wants.

    Why not declare the field as list[str] and let pydantic-settings
    parse it? Because for list fields it expects *JSON* in the env var
    (["http://a","http://b"]), and nobody writes .env files like that.
    A plain comma-separated string plus this one-liner is friendlier.
    """
    return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
  • CORS — Cross-Origin Resource Sharing — is the browser rule that a page served from localhost:5173 may not read a response from localhost:8000 unless the server says it may. Non-browser clients — curl, the tests, the MCP server — never notice it. The frontend and the chat client both run in a browser, so both origins are listed. And because we send an Authorization header cross-origin, allow_credentials=True is required, which in turn means the origin list must be explicit — no wildcard. That is why it comes from config at all.
  • The env var is a string, not a list[str]. pydantic-settings parses list fields from the environment as JSON, so the “correct” form would be CORS_ORIGINS=["http://…","http://…"]. Nobody writes .env files like that. A comma-separated string plus a one-line property is friendlier to whoever edits it next.
  • JWT_SECRET is shared. The MCP server will hold the same value, so it can reject a forged token instantly without calling the backend. .env.example says so in capital letters. The backend still re-verifies — it is the real authority — but the shared secret is a coupling worth knowing about, and it is exactly what an asymmetric key (RS256) would remove in a later phase.

How you know it holds

Fifteen tests in , every one of them through HTTP. None calls current_user directly. That rule — test like the real client, not an ideal one — is the hardest lesson from the previous project, where a test built the perfect request, passed happily, and shipped something every real client rejected. The new client fixture in is FastAPI’s TestClient: real headers, real status codes, real JSON, no network. Three of the fifteen carry the post’s argument:

fastapi-backend/tests/test_auth.py
def test_me_without_token_is_401_not_403(client: TestClient) -> None:
    """Missing credentials is 401 ("who are you?").

    FastAPI's HTTPBearer default is 403 here, which is wrong by RFC 6750
    and would confuse the MCP server's error mapping later. This pins the
    auto_error=False choice in deps.py.
    """
    r = client.get("/auth/me")
    assert r.status_code == 401
    assert r.headers["www-authenticate"] == "Bearer"


def test_me_with_tampered_signature_is_401(client: TestClient) -> None:
    """Flip the last character of the signature. The claims are untouched,
    so a decoder that skipped verification would happily accept it."""
    _register(client)
    token = _login(client).json()["access_token"]
    head, payload, sig = token.split(".")
    bad_sig = sig[:-1] + ("A" if sig[-1] != "A" else "B")
    r = client.get("/auth/me", headers=_bearer(f"{head}.{payload}.{bad_sig}"))
    assert r.status_code == 401


def test_token_carries_no_roles(client: TestClient) -> None:
    """ADR 0002's notable detail, pinned: claims are exactly sub/email/iat/exp.

    If someone adds ``role`` to the token, promotions stop taking effect
    until re-login, and this test explains why that's a regression.
    """
    import jwt  # decoded *without* verification — we only want the claim names

    _register(client)
    token = _login(client).json()["access_token"]
    claims = jwt.decode(token, options={"verify_signature": False})
    assert set(claims) == {"sub", "email", "iat", "exp"}
  • The 401-vs-403 test can only exist at the HTTP layer. We set auto_error=False up front, so nothing ever failed — but only this test keeps it that way. A test that called current_user as a function could never notice the header-less case, because FastAPI answers it before the function is ever called.
  • The tamper test edits one character of the signature and leaves the claims alone. A decoder that read the payload without verifying would accept it. Ours doesn’t.
  • The no-roles test is a fence around a decision. It decodes the token without verifying — we only want the claim names — and asserts the set is exactly four. If a future change adds role, the test fails and its docstring explains the regression: promotions stop taking effect until re-login.

The rest cover the paths you’d expect once you know what to look for: duplicate email is 409 and case-insensitive; a 73-character password is 422; unknown email and wrong password return identical JSON; an expired token, a garbage token, and a token for a deleted user are all 401; and the seed’s demo password logs in through the same hash function the seed used.

What went wrong

EmailStr failed at import time, not at validation time

the symptom
ImportError: email-validator is not installed, run pip install 'pydantic[email]'

Not on the first bad request — on from app.schemas.auth import …, which means the whole app refuses to start. pydantic builds a model’s validation schema when the class body executes, and EmailStr’s schema builder imports email_validator right then. It is an optional extra of pydantic, and neither fastapi nor fastapi[standard] pulls it in. FastAPI’s docs use EmailStr in examples without mentioning the extra. The fix is "pydantic[email]" in , with a comment. For a trainee the surprise is the when: a validation helper that breaks startup.

Starlette wants httpx2, not httpx

the symptom
StarletteDeprecationWarning: Using 'httpx' with 'starlette.testclient' is deprecated; install 'httpx2' instead.

The tests passed, with a warning nobody should ignore. httpx 2.x is published on PyPI under a different name, httpx2, so 1.x and 2.x can coexist. Starlette’s test client tries import httpx2 as httpx first and falls back to httpx with the warning — a fallback that will presumably disappear in a later release. The dev dependency is now httpx2. Every FastAPI testing tutorial you have read says pip install httpx; the FastAPI docs still do. The only hint is the warning text itself.

uv add rewrites pyproject.toml

After uv add fastapi uvicorn pyjwt, the carefully commented dependency list had lost every blank line between comment blocks, and the new entries sat bare at the end. uv add --group dev then re-sorted the dev group, leaving a comment orphaned above the wrong package. uv add re-serialises the array: it keeps comments attached to lines, drops the spacing between blocks, and keeps groups sorted. Nothing in uv add --help mentions any of this. Small — but this repo’s pyproject.toml is a teaching document, so the rule is now: hand-edit after every uv add, then check uv lock --check still passes.

Said plainly: what this auth does not do

  • No refresh tokens, and no logout that invalidates anything. A token is good until it expires. Deactivating the user is the only server-side kill switch.
  • No rate limiting on /auth/login. Brute-forcing is slowed only by bcrypt itself.
  • Login timing leaks “email exists” a little — see above.
  • Still passlib, still the bcrypt pin from Phase 1. Migrating to pwdlib or raw bcrypt would be a phase with nothing to teach about MCP. The seed and login now share one CryptContext, so when the migration happens it touches exactly one file.
  • The backend runs on your machine, not in a container. Phase 1’s notes expected the container to arrive here. The design doc puts all four Dockerfiles in Phase 12, and the design doc wins.

Run it

Check out the Phase 2 commit so your tree matches every snippet above. You need the Compose database from Phase 0 running and uv installed. Dependencies changed this phase, so run uv sync again.

bash
git clone https://github.com/Jayantkhandebharad/mcp-taskflow.git
cd mcp-taskflow
git checkout 8bbd4e6

cp .env.example .env
docker compose up -d db

cd fastapi-backend
uv sync
uv run alembic upgrade head
uv run python -m scripts.seed      # demo users; every one has the password "password"
uv run pytest                      # 6 from phase 1 + 15 auth tests = 21 passed

uv run uvicorn app.main:app --reload --port 8000

In a second terminal, do the ceremony from the top of the post by hand:

bash
# 1. log in as Alice — JSON in, token out
curl -s -X POST http://localhost:8000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"password"}'
# {"access_token":"eyJhbGciOiJIUzI1NiIs...","token_type":"bearer"}

# 2. keep the token, and ask who you are
TOKEN=$(curl -s -X POST http://localhost:8000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"password"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')

curl -s http://localhost:8000/auth/me -H "Authorization: Bearer $TOKEN"
# {"id":"96ef51a2-db48-5b2c-9d4b-19aec65e71c2","email":"alice@example.com","full_name":"Alice Adams","is_active":true,"created_at":"..."}

Then try the three ways it should refuse, and notice the shape of each refusal:

bash
# no token → 401, not 403, with the WWW-Authenticate header
curl -si http://localhost:8000/auth/me | head -3
# HTTP/1.1 401 Unauthorized
# www-authenticate: Bearer

# wrong password, and an email that doesn't exist — identical bodies
curl -s -X POST http://localhost:8000/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"nope"}'
curl -s -X POST http://localhost:8000/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"nobody@example.com","password":"nope"}'
# {"detail":"Invalid email or password"}
# {"detail":"Invalid email or password"}

Finally, paste the token into any JWT decoder and read it. You will see sub, email, iat, exp — and nothing about which projects Alice is an admin of. That absence is the design.

What’s next

We can say who is calling. We can’t yet say what they may do. Next post is the backend: every route in the design doc — projects, members, tasks, comments — behind require_member and require_admin, both built on the current_user you just read. It is also where the composite foreign key from Phase 1 bites for real: the “remove member” route has to decide what happens to that member’s open tasks.

  • Store a bcrypt hash, never a password. Compare in constant time. One function for hashing, shared by the seed and the login.
  • A JWT is readable by anyone and forgeable by no one without the secret. Always pass algorithms=[...].
  • Roles are not in the token. They are looked up per request, so promotions are instant and stolen tokens carry nothing stale.
  • current_user is one dependency: read the header, verify, load the row, refuse the inactive. Everything else stacks on it.
  • Missing token is 401, not 403. Every login failure returns the same body. Only HTTP-level tests can see either.
  • pydantic[email] breaks at import, httpx2 is the new httpx, and uv add will reformat your comments.
series progress0%
Code & notebooks for this series