FastAPI – Authentication and Authorization

September 26, 202514 min readUpdated 8/23/2026

Authentication is who you are. Authorization is what you may do. Most tutorials cover the first and stop, which leaves the harder and more consequential half undone. This lesson does both, using StayHub's real implementation — password hashing, a JWT two services both trust, and authorization expressed as dependencies so a route signature states its own rules.

Passwords

Never store a password. Store a slow, salted hash of one:

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(plain: str) -> str:
    return pwd_context.hash(plain[:72])


def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain[:72], hashed)

bcrypt is slow by design, and that is the entire point. A general-purpose hash like SHA-256 is built to be fast, which is exactly wrong here: fast means a leaked database can be brute-forced at billions of guesses per second. bcrypt takes a deliberate fraction of a second, and the work factor can be raised as hardware improves.

Salting is automatic. pwd_context.hash generates a random salt per password and stores it inside the resulting string, so two users with the same password get different hashes and a precomputed rainbow table is useless.

deprecated="auto" is the upgrade path. Add a stronger scheme to the list later and passlib verifies old hashes with the old scheme while marking them for rehash — so you can migrate algorithms without asking anybody to reset a password.

The 72-byte truncation

    # bcrypt silently truncates at 72 BYTES. Rejecting long passwords instead would surprise users;
    # truncating is what every bcrypt implementation does, so be explicit that it happens.

bcrypt ignores everything past 72 bytes. Not characters — bytes, so a passphrase of emoji or non-Latin script hits the limit far sooner than it looks. Every implementation does this and most do it silently; slicing explicitly means the behaviour is visible in the code rather than a surprise in a library.

It matters because it is a real, if narrow, security property: two long passwords sharing their first 72 bytes are the same password as far as bcrypt is concerned.

The token

A JWT is three base64 segments — header, claims, signature — joined by dots. The claims are signed, not encrypted. Anyone holding the token can read them; nobody without the secret can change them.

That single fact decides what may go in one. An id and a role, yes. An email address, probably. Anything you would not write on a postcard, no.

    payload = {
        "sub": user_public_id,
        "iat": now,
        "exp": now + timedelta(minutes=settings.access_token_expire_minutes),

sub is the subject — who this token is about. iat is when it was issued, exp when it stops being valid. Note sub is the public UUID, not the database's integer primary key: a token is handed to a browser, and the same reasoning that keeps sequential ids out of URLs keeps them out of tokens.

Decoding is deliberately uninformative about failure:

def decode_access_token(token: str) -> dict | None:
    """Returns the claims, or None for anything invalid — expired, tampered with, or malformed.

    The caller turns None into a 401. Distinguishing *why* a token is bad would tell an attacker
    which half of their guess was right.
    """
    try:
        return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
    except JWTError:
        return None

algorithms=[...] is not optional and not a formality. Passing an explicit list is what prevents the algorithm confusion attack: a JWT's header names its own algorithm, and a library that trusts that header will happily verify a token claiming "alg": "none", or verify an RS256 token using the public key as an HMAC secret. Naming the algorithms you accept closes both.

One login, two services

StayHub's token carries an extra namespace, and it is the reason one sign-in works across two different APIs:

        HASURA_CLAIMS_NAMESPACE: {
            "x-hasura-default-role": default_role,
            "x-hasura-allowed-roles": allowed,
            "x-hasura-user-id": user_public_id,
            "x-hasura-is-host": str(is_host).lower(),
        },

FastAPI signs the token; a GraphQL service is given the same secret and verifies the signature itself. No auth webhook, no shared session store, no second login. That is the general pattern for JWTs across services: the signature is the trust, so anything holding the key can authenticate independently.

Two details in there cost real time to discover. Every x-hasura-* claim must be a string, including ids and booleans — hence str(is_host).lower() — because session variables are compared as text and a JSON number fails deep inside a permission check that points at a table rather than at the token. And the staff role is called staff, not admin, because admin is reserved in that system: it bypasses every rule and cannot have rules declared for it, so naming your role that mints tokens for a role whose permissions can never be expressed.

The general lesson survives the specifics: claims are a contract with whatever consumes them. Read that system's rules before designing your token.

Registering and signing in

    def register(self, payload: UserRegisterRequest) -> AuthResponse:
        email = payload.email.strip().lower()
        if self.users.email_exists(email):
            raise ConflictException("An account with that email already exists.")

Normalising the email is a small thing that prevents a real problem — without .lower(), Sam@example.com and sam@example.com are two accounts, and the second person to sign up gets a confusing failure at login instead of at registration.

The line that matters most is a single hardcoded value:

            # ⚠️ HARDCODED. Registration always creates a CUSTOMER. If this ever reads a role off
            # the request body, anyone can POST themselves an admin account.
            role=UserRole.CUSTOMER,

Privilege must never be assignable from a request body. The safest version of this rule is structural rather than defensive: leave the field off the request model entirely, as lesson 3 showed with prices, and there is nothing to forget to check.

Contrast the two error messages. Registration must admit the email is taken — the user cannot proceed otherwise. Login must not:

        if user is None or not verify_password(password, user.password_hash):
            raise UnauthorizedException("Email or password is incorrect.")

One message for both cases. Distinguishing them turns the login form into an account-enumeration oracle: try an email, and the error tells you whether it is registered. The asymmetry with registration is deliberate and worth documenting where you make it, because it looks like an inconsistency to the next reader.

Verifying on every request

bearer_scheme = HTTPBearer(auto_error=False)

HTTPBearer parses Authorization: Bearer <token> and puts it in the OpenAPI schema, so /docs grows an Authorize button. auto_error=False means a missing header arrives as None rather than FastAPI raising its own error with a different body shape — the one-error-shape rule from the last lesson.

Then the dependency that turns a token into a user:

    claims = decode_access_token(credentials.credentials)
    if claims is None or not claims.get("sub"):
        raise UnauthorizedException("Your session has expired. Please sign in again.")

    user = db.execute(
        select(User).where(User.public_id == claims["sub"], User.deleted.is_(False))
    ).scalar_one_or_none()

    if user is None:
        raise UnauthorizedException("Your account is no longer active.")
    return user

The database lookup is the part people optimise away, and should not:

    # ⚠️ The user is re-read from the database on every request, not trusted from the token. A
    # token issued an hour ago says nothing about whether the account has since been deleted or
    # demoted. The token proves WHO; the database says what they currently are.

A JWT is a snapshot of the moment it was signed. Trusting the role inside it means a demoted user keeps their old permissions until it expires, and a deleted account keeps working. One indexed lookup per request buys immediate revocation, and it is the answer to the standard objection that JWTs cannot be revoked — they can, if you check something.

HTTPBearer or OAuth2PasswordBearer?

FastAPI offers both and the docs lead with the second, which confuses people into thinking they need OAuth2.

from fastapi.security import HTTPBearer, OAuth2PasswordBearer

bearer = HTTPBearer(auto_error=False)                       # "there is a bearer token"
oauth2 = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login") # "...and here is where to get one"

They parse the identical header. The difference is entirely in the OpenAPI document: OAuth2PasswordBearer declares a token endpoint, so Swagger UI shows a username and password form and fetches a token for you.

That is genuinely convenient — but it expects application/x-www-form-urlencoded with fields named exactly username and password. StayHub's login takes JSON with an email field, because its clients are React apps, so HTTPBearer is the honest declaration. Choosing OAuth2PasswordBearer and then not implementing that contract gives you a docs page whose Authorize button does not work.

If you want the button, use OAuth2PasswordRequestForm as your login body and accept form encoding. If you do not, use HTTPBearer and paste the token.

Refresh tokens

The seven-day access token above is a demo simplification. The production shape is two tokens with different jobs:

AccessRefresh
Lifetime15–60 minutesdays to weeks
Senton every requestonly to /auth/refresh
Stored server-sidenoyes — so it can be revoked
If stolenuseful for minutesuseful until revoked

The asymmetry is the whole design. The token sent constantly is short-lived, so stealing it buys little. The long-lived one is sent rarely and is stored, so revoking it actually works — which is what "log out everywhere" means.

Two rules make the difference between this helping and not. Store refresh tokens hashed, exactly like passwords: a leaked table of live refresh tokens is a leaked table of sessions. And rotate on use — each refresh issues a new one and invalidates the old, so a stolen refresh token stops working the moment the real user refreshes, and the collision tells you a theft happened.

Authorization is the other half

Once you have the user, "may they?" is a separate question with its own answers:

def require_host(user: CurrentUser) -> User:
    """Gate for everything under /hosts. Note it checks the flag, not a role (decision D1)."""
    if not user.is_host:
        raise ForbiddenException("You need a host account to do that.")
    return user


def require_admin(user: CurrentUser) -> User:
    if user.role != UserRole.ADMIN:
        raise ForbiddenException("Staff access only.")
    return user

Note that these are two different kinds of check. is_host is a capability — a mode an ordinary account can turn on, which grants nothing else. role is a privilege level. Conflating them is how "host" ends up one rename away from "admin"; keeping them on separate axes means a host can never accidentally acquire staff access.

Then the aliases, and forty route signatures that document themselves:

HostUser = Annotated[User, Depends(require_host)]
AdminUser = Annotated[User, Depends(require_admin)]

Ownership is not a role

Role checks answer "what kind of user is this?". Most authorization questions are actually "is this their thing?", and that needs the object:

        if booking.guest_id != actor.id and actor.role != "ADMIN":
            raise NotFoundException("Booking not found.")

A dependency cannot answer that without loading the resource, which is why ownership checks live in the service beside the load. Note the 404 — a 403 would confirm the booking exists, and on a guessable id that is an enumeration oracle.

The related rule from the same file: a guest may cancel their own booking and staff may cancel any, but a host may not cancel on a guest's behalf. That is a support action, not a hosting one. Authorization is frequently this specific, and no role system expresses it — which is why it belongs in a service rather than in a permissions matrix.

Re-issue the token when what it says changes

    def become_host(self, user: User, host_bio: str | None) -> AuthResponse:
        """Flip the host flag — and re-issue the token.

        ⚠️ Re-issuing is not optional. The Hasura roles are baked into the JWT at sign-in, so a
        user who becomes a host while holding an old token still carries
        `allowed_roles: [customer, anonymous]`. Every host GraphQL query then fails with a
        permission error that looks like a broken Hasura config rather than a stale token.
        """

This is the cost of putting anything in a token: it is a copy, and copies go stale. StayHub gets away with a stale is_host for its own API — that is re-read from the database — but the second service trusts the claim, so the claim has to be refreshed.

The general rule: anything a consumer trusts from the token must be re-issued when it changes, and the endpoint that changes it must return the new token.

Testing it

Auth code is where a subtle bug is most expensive, and it is unusually easy to test because it is all plain functions:

    def test_it_carries_the_hasura_claims_namespace(self):

StayHub asserts on the claims themselves — that every one is a string, that the default role is in the allowed list, that a tampered token fails to decode. Those are properties, not examples, and they hold whatever the payload is.

The other half is the route level, where dependency_overrides makes the negative cases cheap. Swap in a plain customer and the same admin route must refuse:

            assert TestClient(app).get("/api/v1/admin/users").status_code == 403
            assert TestClient(app).get("/api/v1/admin/users").status_code == 401

Both are one line, and they are the two tests people skip. A test that a route works when you are allowed is worth much less than a test that it refuses when you are not — the first fails loudly in development, the second fails silently in production.

Tokens or sessions?

Worth answering explicitly, because JWTs are frequently chosen by default.

A session is an opaque id in a cookie, with the state on the server. Revocation is instant, the cookie carries no information, and every request costs a session lookup. That is a perfectly good design and the right default for a server-rendered application with one backend.

A token carries its own claims, so a service can validate it without asking anybody. That is what buys StayHub one login across two services with no shared session store, and it is the reason to choose one. The cost is that the claims are a copy, and revocation needs something extra — a short expiry, a re-read from the database, or a denylist.

Choose a token when several independent services must trust the same login, or when there is no convenient shared store. Choose a session when there is one backend and one frontend, and enjoy the simpler revocation.

For service-to-service traffic, neither: a long-lived API key per caller, stored hashed, scoped to what that caller may do, and rotatable without touching anybody else. A user token issued to a machine is a user token that never expires.

The endpoints a token API needs

Four, and the fourth is the one people forget:

@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
def register(payload: UserRegisterRequest, db: DbSession) -> AuthResponse:
    """Create an account and sign in immediately.

    The response carries a JWT that Hasura also accepts — one login for both APIs.
    """
    return AuthService(db).register(payload)
@router.get("/me", response_model=UserResponse)
def me(user: CurrentUser) -> UserResponse:
    """Who the current token belongs to. The frontends call this on boot to revive a session."""
    return UserResponse.model_validate(user)

/me is what makes a page refresh work. The browser has a token in storage and no idea whether it is still valid or who it belongs to; one call answers both, and a 401 is the signal to clear it and show the sign-in form. Without it, a frontend has to decode the JWT itself — which means trusting claims that may be hours stale.

Note that registration returns a token rather than requiring a second login. That is a small courtesy with a real effect on completion rates, and it costs nothing because the account was just verified by creating it.

The fourth endpoint is whatever changes what the token asserts — here, becoming a host — and it must return a new one.

The mistakes that actually ship

Five, roughly in order of how often they appear in a code review.

Role from the request body. A registration model with a role field is an admin account for anybody who reads the API documentation. The structural fix is to leave it off the model entirely rather than to remember to ignore it.

Authorization checked in the frontend only. Hiding a button is a courtesy. Every endpoint is directly reachable, and a hidden button is not a permission — which is why StayHub re-checks the cancellation deadline server-side even though it also tells the UI whether to show the button.

A token with no expiry. exp is optional in the specification and mandatory in practice. Without it, a token leaked once is valid forever, and there is no revocation story at all.

Secrets from a default. A JWT secret with a fallback value means a misconfigured deployment signs tokens anybody can forge, and nothing anywhere reports a problem.

The same token for users and services. A machine that never logs out needs an API key, scoped and rotatable, not a user token with a long expiry attached to somebody's account.

Where the checks belong

A useful way to place a new rule:

QuestionBelongs in
Is the request well formed?a pydantic model
Who is calling?a dependency — get_current_user
What kind of user are they?a dependency — require_host
Is this their resource?a service, beside the load
Is this allowed right now?a service — it needs state
Could two requests race?a database constraint

The gradient is how much context the check needs. A schema knows only the payload; a dependency knows the user; a service knows the world; the database knows what other transactions are doing. Putting a check higher than it belongs makes it bypassable, and lower than it belongs makes it unreadable.

Practical decisions

Expiry. StayHub's is seven days, which is a demo convenience. Production wants a short access token — fifteen minutes to an hour — plus a refresh token that can be revoked. The trade is real: shorter expiry means faster revocation and more refresh traffic.

Where the browser keeps it. localStorage is readable by any script on the page, so an XSS becomes a token theft. An HttpOnly cookie is not readable by script but is sent automatically, which reintroduces CSRF and needs SameSite. Neither is free; pick knowing which attack you are choosing to defend against.

HS256 or RS256. HS256 uses one shared secret, so everything that verifies can also sign. That is fine between services you control — StayHub's two share one key. RS256 signs with a private key and verifies with a public one, so a service can validate tokens without being able to mint them. Use it the moment a verifier is not fully trusted.

Rate-limit the login endpoint. bcrypt makes offline cracking expensive and does nothing about online guessing, and the deliberately vague error message means an attacker learns nothing per attempt except by volume.

Next: file uploads — the endpoint where user input stops being JSON and starts being bytes on your disk.