Security Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. The code was executed on Python 3.13 with TestClient. It is the foundation of the security pages: it explains what FastAPI’s security utilities do and do not do, and then builds the simple schemes (HTTP Basic, API keys, HTTP Bearer) on top of them. The OAuth2 password flow with JWT, and scopes and authorization models, have their own pages: OAuth2 Password Flow & JWT and OAuth2 Scopes & Authorization. The protocol theory (grants, tokens, OpenID Connect) is not repeated here; it lives in the OAuth Reference.

Authentication vs Authorization

The two words are often used interchangeably, and the confusion causes real bugs.

Authentication (authn) Authorization (authz)

Question

Who are you?

What may you do?

Result

An identity: a user, a service account, or nobody.

A decision: allow or deny this action on this resource.

Failure status

401 Unauthorized (despite the name it means "unauthenticated"), with a WWW-Authenticate header.

403 Forbidden: the identity is known and is not allowed.

In FastAPI

A dependency that uses a class from fastapi.security to extract credentials, and a function that validates them.

Further dependencies that receive the authenticated user and check roles, scopes or ownership.

FastAPI does not implement either for you. The classes in fastapi.security do exactly two things: they extract credentials from the request (the Authorization header, or a key in a header, query string or cookie) and they declare a security scheme in the generated OpenAPI document, which makes the interactive docs show an Authorize button. Checking that the credentials are valid, looking up the user, hashing passwords and deciding what the user may do is your code. A route that depends on HTTPBearer() accepts any token whatsoever until your own dependency validates it; the class alone secures nothing.

The pattern used throughout this section is a chain of small dependencies (see Dependency Injection):

  • a scheme dependency extracts the raw credentials;

  • an authentication dependency validates them and returns the user, or raises 401;

  • authorization dependencies take that user and raise 403 when the user may not perform the action.

The OAuth Reference covers the same split from the protocol side in Getting Started with OAuth and Scopes, Claims and Permissions.

HTTPS First

Every scheme on this page sends a secret with each request. A Basic header is only Base64-encoded (an encoding, not encryption), and a bearer token or API key is a password for whoever holds it. Without TLS anyone on the network path can read and replay them. So:

  • Serve the API over HTTPS everywhere outside local development. The official documentation treats HTTPS as something the outside world provides: a TLS termination proxy (Traefik, Nginx, a cloud load balancer) holds the certificate and forwards plain HTTP to the application. See Middleware, CORS & Proxies for running behind a proxy and Deployment for servers and containers.

  • Do not put credentials where they are logged by default. A query string such as ?api_key=…​ lands in access logs, proxy logs, browser history and Referer headers; prefer a header.

  • If a cookie carries a credential, set it with the Secure and HttpOnly attributes; see Headers & Cookies.

  • The best-practice checklist for tokens (short lifetimes, storage, rotation) is in Security Best Practices.

The OpenAPI Security Schemes and the fastapi.security Classes

OpenAPI 3 has four types of security scheme, and FastAPI has a class for each. The classes are callable dependencies: you wrap them in Depends() (or Security(), covered on the scopes page), FastAPI runs them, and registers the scheme under components.securitySchemes.

Table 1. Security scheme types and the matching classes
OpenAPI type FastAPI class What it extracts Declared as

apiKey

APIKeyHeader, APIKeyQuery, APIKeyCookie (parameter name=)

A key from a named header, query parameter or cookie.

type: apiKey, in: header, query or cookie, and name

http

HTTPBasic, HTTPBearer, HTTPDigest

The Authorization header: a username and password (HTTPBasicCredentials), or a scheme plus credentials string (HTTPAuthorizationCredentials).

type: http, scheme: basic, bearer or digest

oauth2

OAuth2PasswordBearer, OAuth2AuthorizationCodeBearer, and the generic OAuth2

The bearer token from the Authorization header, plus metadata about where tokens are obtained so Swagger UI can offer a login form.

type: oauth2 and flows

openIdConnect

OpenIdConnect

The bearer token, plus the URL of the provider’s discovery document.

type: openIdConnect and openIdConnectUrl

OAuth2PasswordRequestForm, OAuth2PasswordRequestFormStrict, SecurityScopes, HTTPBasicCredentials and HTTPAuthorizationCredentials are data holders, not schemes. Details that apply to the classes:

  • Every class accepts scheme_name (the key under securitySchemes, defaulting to the class name, which matters when you use two instances of the same class), description (shown in the docs) and auto_error (default True; see below).

  • HTTPBasic also takes realm; HTTPBearer takes bearerFormat, a hint such as JWT that is shown in the docs; OAuth2PasswordBearer takes tokenUrl, scopes and refreshUrl; OAuth2AuthorizationCodeBearer takes authorizationUrl, tokenUrl, refreshUrl and scopes; OpenIdConnect takes openIdConnectUrl.

  • HTTPDigest extracts the digest header and declares the scheme, but, like every class here, verifies nothing. Digest authentication is rarely used in new APIs; prefer Bearer.

  • The OAuth2 classes are the topic of the following pages. For the grants themselves see Choosing an OAuth Flow, and for OpenID Connect see OpenID Connect.

The following app declares one scheme of each family on a tiny route each and is used to print what reaches OpenAPI and what each class answers when credentials are missing:

from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.security import (
    APIKeyHeader,
    HTTPBasic,
    HTTPBearer,
    HTTPDigest,
    OAuth2AuthorizationCodeBearer,
    OAuth2PasswordBearer,
    OpenIdConnect,
)

app = FastAPI()

schemes = {
    "basic": HTTPBasic(),
    "bearer": HTTPBearer(),
    "digest": HTTPDigest(),
    "apikey": APIKeyHeader(name="X-API-Key"),
    "password": OAuth2PasswordBearer(tokenUrl="token"),
    "authcode": OAuth2AuthorizationCodeBearer(
        authorizationUrl="https://idp.example.test/authorize",
        tokenUrl="https://idp.example.test/token",
    ),
    "oidc": OpenIdConnect(
        openIdConnectUrl="https://idp.example.test/.well-known/openid-configuration"
    ),
}

for name, scheme in schemes.items():
    # One tiny route per class, just to show what each one declares in OpenAPI.
    def endpoint(credentials: Annotated[object, Depends(scheme)]):
        return {"ok": True}

    app.get(f"/{name}")(endpoint)

The generated components.securitySchemes, taken from a real run:

{
  "HTTPBasic": {"type": "http", "scheme": "basic"},
  "HTTPBearer": {"type": "http", "scheme": "bearer"},
  "HTTPDigest": {"type": "http", "scheme": "digest"},
  "APIKeyHeader": {"type": "apiKey", "in": "header", "name": "X-API-Key"},
  "OAuth2PasswordBearer": {"type": "oauth2", "flows": {"password": {"scopes": {}, "tokenUrl": "token"}}},
  "OAuth2AuthorizationCodeBearer": {
    "type": "oauth2",
    "flows": {
      "authorizationCode": {
        "scopes": {},
        "authorizationUrl": "https://idp.example.test/authorize",
        "tokenUrl": "https://idp.example.test/token"
      }
    }
  },
  "OpenIdConnect": {
    "type": "openIdConnect",
    "openIdConnectUrl": "https://idp.example.test/.well-known/openid-configuration"
  }
}

Calling each route without credentials returns 401 with a WWW-Authenticate header naming the scheme (since 0.122.0, see the 401 vs 403 section):

Table 2. Default WWW-Authenticate value per class when credentials are missing
Class WWW-Authenticate

HTTPBasic

Basic

HTTPBearer, OAuth2PasswordBearer, OAuth2AuthorizationCodeBearer, OpenIdConnect

Bearer

HTTPDigest

Digest

APIKeyHeader, APIKeyQuery, APIKeyCookie

APIKey

What the declaration does on the operation is as important as the scheme itself: every route that depends on a scheme gets a security requirement (for example [{"HTTPBasic": []}]), and Swagger UI uses it to show the padlock and to send the credentials you entered in the Authorize dialog. The route is documented as protected even though the enforcement is entirely your dependency. See OpenAPI & Interactive Docs for the rest of the document.

The auto_error Option and Optional Authentication

By default each class raises an HTTPException itself when its credentials are absent (or, for Basic and Bearer, malformed). Passing auto_error=False turns that off: the dependency returns None instead, and the decision moves to your code. That is how you build:

  • optional authentication: anonymous callers see the public catalogue, authenticated ones also see their own reviews;

  • alternatives: accept a bearer token or an API key or a cookie; the examples below combine several schemes, and OpenAPI lists them as alternatives.

HTTP Basic

HTTP Basic authentication (RFC 7617) sends Authorization: Basic <base64(username:password)> with every request. It is simple, supported by every HTTP client and by browsers, which show a native login dialog when they receive 401 with WWW-Authenticate: Basic. It is appropriate for internal tools, admin endpoints behind a VPN, and machine-to-machine calls over HTTPS, and not for end-user logins (no logout, no expiry, the password travels on every request).

HTTPBasic parses the header and gives you HTTPBasicCredentials with username and password. Your code compares them with the expected values. Two rules matter:

  • Compare in constant time. Python’s == on strings stops at the first differing character, so the response time leaks how much of a guess was correct. An attacker who can measure it (a timing attack) can recover a secret one character at a time, in principle. secrets.compare_digest(a, b) takes the same time regardless of where the inputs differ.

  • Encode to bytes first. compare_digest accepts str only when both are ASCII; a username with a non-ASCII character raises TypeError: comparing strings with non-ASCII characters is not supported, which would surface as a 500. Encoding both sides with .encode("utf-8") removes the problem. Evaluate both comparisons before combining the results (user_ok and password_ok above) so a wrong username does not skip the password check and reveal which part was wrong.

The expected values come from the environment (see Settings, Lifespan & Background Tasks for pydantic-settings); the example fails closed when they are unset, because an empty expected value is never accepted.

import os
import secrets
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()
security = HTTPBasic(realm="bookshelf-admin")

# In a real app these come from settings / a secret store, never from source code.
ADMIN_USER = os.environ.get("BOOKSHELF_ADMIN_USER", "")
ADMIN_PASSWORD = os.environ.get("BOOKSHELF_ADMIN_PASSWORD", "")


def get_admin(credentials: Annotated[HTTPBasicCredentials, Depends(security)]) -> str:
    user_ok = secrets.compare_digest(
        credentials.username.encode("utf-8"), ADMIN_USER.encode("utf-8")
    )
    password_ok = secrets.compare_digest(
        credentials.password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")
    )
    if not (ADMIN_USER and ADMIN_PASSWORD and user_ok and password_ok):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": 'Basic realm="bookshelf-admin"'},
        )
    return credentials.username


AdminDep = Annotated[str, Depends(get_admin)]


@app.get("/admin/stats")
def stats(admin: AdminDep):
    return {"admin": admin, "books": 42}

Calling it, with the secrets in shell variables rather than in the command:

export BOOKSHELF_ADMIN_USER=admin
read -rs BOOKSHELF_ADMIN_PASSWORD && export BOOKSHELF_ADMIN_PASSWORD   # typed, not stored in history
curl -i http://127.0.0.1:8000/admin/stats
curl -i -u "$BOOKSHELF_ADMIN_USER:$BOOKSHELF_ADMIN_PASSWORD" http://127.0.0.1:8000/admin/stats

Without credentials the response is 401 with the header WWW-Authenticate: Basic realm="bookshelf-admin", which makes a browser prompt for a login; with valid credentials it is:

{"admin": "admin", "books": 42}

Observations from running the example:

  • A request with no header and a request with a wrong password both answer 401 with the same WWW-Authenticate header. The first has the detail Not authenticated (raised by HTTPBasic itself), the second Incorrect username or password (raised by the example). Use one detail message for every failed credential check, and never say which of the two fields was wrong.

  • The 401 response must carry WWW-Authenticate. Without it the response is not valid per RFC 9110 and browsers do not show their login dialog. When you raise the exception yourself, add the header as shown.

  • realm="bookshelf-admin" is the optional protection-space name; it only labels the browser prompt. The realm argument of HTTPBasic sets it for the built-in error.

  • The security scheme in OpenAPI is {"type": "http", "scheme": "basic"} and the operation gets security: [{"HTTPBasic": []}]. Swagger UI shows a username and password form in the Authorize dialog.

  • HTTP Basic has no logout and the browser keeps resending the credentials for the session. A store of users with hashed passwords, and a login endpoint that returns a token, are covered in OAuth2 Password Flow & JWT.

Tutorials that compare with == and hard-code the credentials show two things to avoid; read secrets from settings and use compare_digest.

API Keys in a Header, Query String or Cookie

An API key is a long random string that identifies a calling application. It is the simplest scheme for server to server integrations and public APIs with per-client quotas. FastAPI has one class per location:

Class Reads Notes

APIKeyHeader(name="X-API-Key")

A request header.

The usual choice: not logged by default and not stored in browser history. The name is case-insensitive.

APIKeyQuery(name="api_key")

A query parameter.

Convenient for quick scripts and links, and the least safe: URLs appear in logs, history and Referer headers.

APIKeyCookie(name="bookshelf_key")

A cookie.

Useful when the caller is a browser that you want to authenticate without JavaScript touching the token. Cookie-based credentials are sent automatically by browsers, so they need CSRF protection and a CORS policy; see the middleware page.

Guidelines for the keys themselves, which the framework does not enforce:

  • generate them with secrets.token_urlsafe(32) (a cryptographically secure source) and show them to their owner once;

  • store only a hash, as the example does with SHA-256 (the key is long and random, so a fast hash is enough, unlike passwords; see the password page for slow hashes), and compare hashes with secrets.compare_digest;

  • give each key an owner, scopes and an expiry or revocation flag, so a leaked key can be disabled;

  • load secrets from the environment or a secret manager, never from source control.

The example accepts the key from any of the three places, with auto_error=False so that the three classes become alternatives, and raises one 401 when none matched. Security() is used instead of Depends() for the scheme dependencies because it is the same mechanism with scope support (explained on the scopes page); for an API key without scopes, Depends() works the same way.

import hashlib
import os
import secrets
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import APIKeyCookie, APIKeyHeader, APIKeyQuery

app = FastAPI(title="Bookshelf API")

# Store only a hash of each key. The real value is shown to its owner once, when it is created.
def digest(key: str) -> bytes:
    return hashlib.sha256(key.encode("utf-8")).digest()


KEYS = {digest(os.environ.get("BOOKSHELF_DEMO_KEY", "")): "reporting-service"}

header_scheme = APIKeyHeader(name="X-API-Key", scheme_name="ApiKeyHeader", auto_error=False)
query_scheme = APIKeyQuery(name="api_key", scheme_name="ApiKeyQuery", auto_error=False)
cookie_scheme = APIKeyCookie(name="bookshelf_key", scheme_name="ApiKeyCookie", auto_error=False)


def get_client(
    from_header: Annotated[str | None, Security(header_scheme)],
    from_query: Annotated[str | None, Security(query_scheme)],
    from_cookie: Annotated[str | None, Security(cookie_scheme)],
) -> str:
    key = from_header or from_query or from_cookie
    if key:
        candidate = digest(key)
        for known, client in KEYS.items():
            if secrets.compare_digest(candidate, known):
                return client
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid or missing API key",
        headers={"WWW-Authenticate": "ApiKey"},
    )


ClientDep = Annotated[str, Depends(get_client)]


@app.get("/reports/books")
def books_report(client: ClientDep):
    return {"client": client, "books": 42}
export BOOKSHELF_DEMO_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
curl -i -H "X-API-Key: $BOOKSHELF_DEMO_KEY" http://127.0.0.1:8000/reports/books
curl -i "http://127.0.0.1:8000/reports/books?api_key=$BOOKSHELF_DEMO_KEY"
curl -i --cookie "bookshelf_key=$BOOKSHELF_DEMO_KEY" http://127.0.0.1:8000/reports/books

All three return the same 200 JSON; a wrong or missing key returns 401 with WWW-Authenticate: ApiKey:

{"client": "reporting-service", "books": 42}

What reaches OpenAPI: one scheme per instance (scheme_name keeps them apart; without it the names would be APIKeyHeader, APIKeyQuery and APIKeyCookie), and the operation lists them as alternatives, any one of which is enough:

[{"ApiKeyHeader": []}, {"ApiKeyQuery": []}, {"ApiKeyCookie": []}]

Run on its own, with the default auto_error=True, each class answers a missing key with 401, the detail Not authenticated and WWW-Authenticate: APIKey.

HTTP Bearer

HTTPBearer reads Authorization: Bearer <token> (RFC 6750) and returns HTTPAuthorizationCredentials with scheme and credentials. It does not care what the token is: an opaque random string, a JWT, a token issued by an identity provider. Use it when the token is obtained somewhere else, or when you do not need the OAuth2 password form in Swagger UI. (OAuth2PasswordBearer, used on the next page, extracts the same header and additionally tells Swagger UI where to obtain a token.) The Authorization dialog for HTTPBearer shows a single token field.

The example validates an opaque token by hashing it and comparing against a stored hash, then returns the owner. Replace the lookup with a database query, or with JWT verification (see the next page and the JWT and JOSE page); opaque-token concerns such as introspection and revocation are in Opaque Tokens, Introspection and Revocation.

import hashlib
import os
import secrets
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

app = FastAPI()
bearer = HTTPBearer(bearerFormat="opaque", description="Opaque access token issued by the Bookshelf API")

# Opaque tokens are looked up (here: compared against one hashed demo value); a JWT would be verified instead.
DEMO_TOKEN_HASH = hashlib.sha256(os.environ.get("BOOKSHELF_DEMO_TOKEN", "").encode()).digest()


def get_token_owner(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> str:
    candidate = hashlib.sha256(credentials.credentials.encode()).digest()
    if not secrets.compare_digest(candidate, DEMO_TOKEN_HASH):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
            headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
        )
    return "alice"


@app.get("/me")
def read_me(owner: Annotated[str, Depends(get_token_owner)]):
    return {"user": owner}
curl -i http://127.0.0.1:8000/me
curl -i -H "Authorization: Bearer $BOOKSHELF_DEMO_TOKEN" http://127.0.0.1:8000/me

The first call returns 401 with WWW-Authenticate: Bearer and {"detail": "Not authenticated"}. A wrong token is rejected by the example’s own check with WWW-Authenticate: Bearer error="invalid_token", which is the form RFC 6750 defines for a token that is present and invalid. A valid token returns:

{"user": "alice"}

A request whose Authorization header uses another scheme (Basic …​) is treated like a missing header by HTTPBearer: the scheme must be Bearer, compared case-insensitively. The security scheme in OpenAPI is {"type": "http", "scheme": "bearer", "bearerFormat": "opaque", "description": …​}; bearerFormat is only documentation.

Combining Schemes and Protecting Whole Routers

Two techniques complete the picture. First, accept more than one scheme by declaring each with auto_error=False and checking which credentials arrived, as above. The WWW-Authenticate value for the final 401 is up to you; the example lists both challenges. Second, protect many routes at once instead of adding the dependency to every function:

  • @app.get(…​, dependencies=[Depends(authenticate)]) for one route;

  • APIRouter(dependencies=[Depends(authenticate)]) for a router (used here for everything under /internal);

  • FastAPI(dependencies=[Depends(authenticate)]) for the whole application, which then needs explicit exceptions for public routes such as /health and the docs, so a router is usually the better unit.

When the route does not need the result, dependencies=[…​] runs the dependency for its effect only. All three levels are explained in Dependency Injection.

import os
import secrets
from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.security import (
    HTTPAuthorizationCredentials,
    HTTPBasic,
    HTTPBasicCredentials,
    HTTPBearer,
)

basic = HTTPBasic(auto_error=False)
bearer = HTTPBearer(auto_error=False)

SERVICE_USER = os.environ.get("BOOKSHELF_SERVICE_USER", "")
SERVICE_PASSWORD = os.environ.get("BOOKSHELF_SERVICE_PASSWORD", "")
SERVICE_TOKEN = os.environ.get("BOOKSHELF_SERVICE_TOKEN", "")


def _same(a: str, b: str) -> bool:
    return bool(b) and secrets.compare_digest(a.encode(), b.encode())


def authenticate(
    basic_credentials: Annotated[HTTPBasicCredentials | None, Security(basic)],
    bearer_credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer)],
) -> str:
    if bearer_credentials and _same(bearer_credentials.credentials, SERVICE_TOKEN):
        return "token-client"
    if (
        basic_credentials
        and _same(basic_credentials.username, SERVICE_USER)
        and _same(basic_credentials.password, SERVICE_PASSWORD)
    ):
        return basic_credentials.username
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Not authenticated",
        headers={"WWW-Authenticate": 'Bearer, Basic realm="bookshelf"'},
    )


# Every route on this router needs a caller; the return value is not used.
private = APIRouter(prefix="/internal", dependencies=[Depends(authenticate)])


@private.get("/health-detail")
def health_detail():
    return {"db": "ok", "queue": "ok"}


app = FastAPI()
app.include_router(private)


@app.get("/health")  # public
def health():
    return {"status": "ok"}

Results of the run: /health is public and returns 200; /internal/health-detail returns 401 without credentials or with a wrong bearer token, and 200 with either the correct Basic credentials or the correct bearer token. The scheme declarations in OpenAPI are:

{"HTTPBasic": {"type": "http", "scheme": "basic"}, "HTTPBearer": {"type": "http", "scheme": "bearer"}}

The protected operation gets security: [{"HTTPBasic": []}, {"HTTPBearer": []}]; the public one has none.

401 vs 403, and the Change in 0.122

The correct statuses are:

  • 401 Unauthorized: the request has no valid authentication. The response must carry WWW-Authenticate naming the scheme the client should use (RFC 9110).

  • 403 Forbidden: the caller is authenticated, but is not allowed to do this. Retrying with the same credentials will not help.

FastAPI’s security classes historically returned 403 Forbidden with the detail Not authenticated when credentials were missing, which is the wrong status for "unauthenticated" and carried no WWW-Authenticate. Since 0.122.0 (2025-11-24) the classes return 401 Unauthorized and a WWW-Authenticate header, as the previous sections showed. Consequences:

  • Clients and tests written for older versions that assert 403 for a missing token now fail on upgrade. Update them to 401 (the better fix), or restore the old status as shown below.

  • 403 remains correct for authorization failures that your dependencies raise (wrong role, missing scope).

  • Browsers can now react to WWW-Authenticate: Basic by showing their login dialog for routes using HTTPBasic.

If clients really depend on 403, override make_not_authenticated_error in a subclass. The method returns the exception (it does not raise it; the class raises what the method returns), and every class in fastapi.security has it. The restore example from the official documentation:

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

app = FastAPI()


class HTTPBearer403(HTTPBearer):
    def make_not_authenticated_error(self) -> HTTPException:
        # Return the exception; the framework raises it.
        return HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated"
        )


CredentialsDep = Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer403())]


@app.get("/me")
def read_me(credentials: CredentialsDep):
    return {"token_length": len(credentials.credentials)}

Running it, a request without the header returns 403 with {"detail": "Not authenticated"} and no WWW-Authenticate header, exactly the pre-0.122 behaviour, while a request with Authorization: Bearer <anything> passes the class (validating the token is still your dependency’s job). The default implementation, which you are replacing, builds a 401 with WWW-Authenticate from make_authenticate_headers().

A subclass per scheme is enough; repeat the pattern for HTTPBasic403(HTTPBasic), APIKeyHeader403(APIKeyHeader) and so on. Because the status is a property of the class, any test that overrides or constructs the scheme must use the same subclass; see Testing.

Third-Party Options

You do not have to write all of this. These community packages build on FastAPI’s security utilities; they are listed here for orientation and are not documented or verified on this page, so read their documentation and check that they support the FastAPI version you pin:

Package What it provides

FastAPI Users

Ready-made registration, login, password reset and email verification, with pluggable backends (cookie, JWT bearer) and database adapters.

AuthX

JWT-based authentication with access and refresh tokens, token storage in headers or cookies, and scopes.

fastapi-sso

"Sign in with" social login (OAuth2 and OpenID Connect providers such as Google and GitHub) as FastAPI route helpers.

For social login and federation theory see Social Login and Federation. When a managed identity provider (Keycloak, a cloud IdP) issues the tokens, FastAPI only needs to validate them, which the scopes page shows.

Where Older Guides Are Outdated, and What Changed Recently

  • 403 for missing credentials (changed in 0.122.0, 2025-11-24). Older tutorials and book-era FastAPI returned 403 Forbidden for a missing Authorization header. Current FastAPI returns 401 with WWW-Authenticate. Restore the old status by overriding make_not_authenticated_error as shown above.

  • Hard-coded credentials and == comparison. Basic-auth examples that hard-code a username and password and compare with == are unsafe; read secrets from settings and compare with secrets.compare_digest.

  • Depends(oauth2_scheme) without Annotated. Material from before 0.95.0 writes token: str = Depends(oauth2_scheme); the current style is token: Annotated[str, Depends(oauth2_scheme)].

  • Dependency results are hashable (0.121.3, 2025-11-19). Not user-visible, but explains old workarounds in libraries that inspect Depends() and Security().

  • Starlette 1.x. FastAPI 0.133.0 (2026-02-24) added support for Starlette 1.0.0 and later; the security classes are FastAPI’s own and unaffected.

The consolidated dated table is in What’s Changed & Migration.

Pitfalls

  • A scheme class is not a check. Depends(HTTPBearer()) only proves a header exists. Always follow it with a validating dependency, and attach that dependency to every protected route (or to the router).

  • Secrets in code or in the OpenAPI document. securitySchemes describes how to authenticate, not the secrets; never put keys in description, defaults or examples.

  • == for secrets. Use secrets.compare_digest on bytes, and on hashes for stored keys.

  • Distinguishing failures. Different detail messages for an unknown user and a wrong password let attackers enumerate accounts. Use one message.

  • Missing WWW-Authenticate. A hand-raised 401 without the header is not valid HTTP and breaks browser prompts.

  • Credentials in query strings leak through logs and Referer; prefer headers.

  • Mixing up 401 and 403. Unknown caller: 401. Known caller, not permitted: 403.

  • Forgetting CORS and CSRF for cookies. Cookie credentials are sent automatically by browsers; see Middleware, CORS & Proxies.

  • No rate limiting. Basic and API-key endpoints are brute-force targets; add limits at the proxy or with middleware.

  • Public docs for a private API. /docs and /openapi.json are public by default; see the OpenAPI page for disabling or protecting them.