"I want to add login to my FastAPI API"—the requirement is one line. But authentication is a domain where one line's mistake becomes information leakage as-is. Storing passwords in plaintext, forgetting to specify algorithms in JWT verification, trusting the Authorization header and mixing up users—each "works," and precisely because it works, you don't notice until it's in production.
This article is a guide to implementing production-quality authentication (AuthN) and authorization (AuthZ) in FastAPI. While following FastAPI's official tutorial flow of OAuth2 + JWT faithfully to the latest official spec, it raises the parts the official docs note as "for demo" (the dummy users in the code, the hardcoded key) into a form that withstands real operation. As source material, I'll weave in design decisions from the in-house AI platform I built for a major Japanese broadcaster (a FastAPI caption typo-detection pipeline; designing a self-made OIDC auth hub that issues short-lived JWTs with audience narrowed per tool) and from a Minister of Economy, Trade and Industry Award-winning lumber-distribution B2B SaaS (protecting all 221 endpoints with authorization and proving zero missing-authorization findings in a third-party pen test).
The rules of this article: APIs and recommended libraries are based on the FastAPI official documentation (as of June 2026). In recent years the official docs updated password hashing from
passlib→pwdlib(Argon2) and JWT frompython-jose→PyJWT. This article conforms to this latest version. Specs are revised, so always confirm the latest behavior in the official docs before going to production. Secrets (SECRET_KEY, DB URL, keys) are assumed to be in environment variables (never hardcode). And OAuth2 itself doesn't encrypt the communication—HTTPS is mandatory in production.
0. First, the map: authentication and authorization, OAuth2, OpenID Connect
Before implementing, align the terms. Write while these are vague and the design will surely waver.
- Authentication (AuthN) = confirming who you are. Login, token verification.
- Authorization (AuthZ) = deciding what you're allowed to do. Permissions, roles, scopes.
These two are different things. "Logged in (AuthN)" does not mean "may view the admin page (AuthZ)." This article covers AuthN in the first half (Chapters 2–5) and AuthZ in the second half (Chapter 6).
Grasp the official organization too.
- OAuth2 is a family of specs handling authentication and authorization. It's a broad spec including methods that go through a third party like "log in with Facebook / Google / GitHub." OAuth2 does not specify encryption of the communication—it's premised on being delivered over HTTPS.
- OpenID Connect (OIDC) is a spec on top of OAuth2 that fills in OAuth2's vague parts and improves interoperability. Google login is OIDC.
- FastAPI provides, in
fastapi.security, tools corresponding to these schemes (apiKey / http / oauth2 / openIdConnect).
OAuth2 has multiple "flows," and this article uses the password (password flow). This is the simplest flow that receives a username and password and authenticates within the same app.
0.1 Implement it yourself, or lean on a managed IdP (the first fork)
This is the first decision of production design. Don't underestimate the weight of the responsibility of "holding users' passwords yourself."
| Aspect | Self-implement in FastAPI (this article) | Managed IdP (Cognito / Auth0 / Clerk) |
|---|---|---|
| Suited case | Single app, internal tool, learning, when full control is needed | When SSO, social login, MFA, or SAML is needed, and you want to delegate operation |
| Password storage | Hash with Argon2 and store it yourself (you bear the responsibility) | The IdP stores it (you don't hold it = the leakage surface shrinks) |
| MFA, password reset | Implement yourself | Standard features |
| Verification | Issue and verify the JWT with your key | Verify with the IdP's public key (JWKS) via RS256 |
The rule of thumb: if there's no necessity to "hold your own users' passwords," consider a managed IdP first (compare auth-platform selection here). This article covers the path of "implementing it correctly yourself," but even when using a managed IdP, the second half's JWT-verification know-how, authorization (scopes), and centralization at the router layer are directly useful (Cognito's RS256 + JWKS verification here).
1. The whole picture: the single stroke of the OAuth2 password flow
First grasp the flow of the finished form. The cast and the movement of data are just this.
- The client POSTs the username and password to
/tokenin form format. - The server verifies the credentials, and on success returns a JWT (access token).
- The client afterward sends, to protected endpoints, an
Authorization: Bearer <token>header. - The server verifies the token with a dependency (
Depends/Security), restores the current user, and injects it into the handler. - The token expires after a set time (keeping it short-lived is the iron rule).
The starting point of this wiring is OAuth2PasswordBearer.
from fastapi.security import OAuth2PasswordBearer
# tokenUrl は「トークンを取りに行く先」を OpenAPI に伝えるだけ。
# このエンドポイント自体を作るわけではない(作るのは第4章)。
# 相対URLにするのが重要:プロキシ配下でもパスが壊れない。
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
Using OAuth2PasswordBearer as a dependency, FastAPI does the following three things automatically.
- Looks at the request's
Authorizationheader, and if it'sBearer <token>, extractstoken: str. - If there's no header / it's not in
Bearerformat, automatically returns401 Unauthorized(you don't write it). - Grows an "Authorize" button and a lock icon on the interactive docs
/docs, so you can try it from a browser.
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
@app.get("/items/")
async def read_items(token: Annotated[str, Depends(oauth2_scheme)]):
# ここに来た時点で token は「Bearerヘッダから取れた文字列」。
# ただし、まだ"中身が正しいか"は検証していない(第5章で検証する)。
return {"token": token}
Key point:
OAuth2PasswordBearer's job ends at "extracting the token string from the header." Verifying whether that token is genuine is still separate. Understand this division and the design that follows falls into place.
2. Store passwords correctly: pwdlib + Argon2
The foundation of authentication is "how to store passwords." Miss this, and no matter how much you firm up everything else, it's meaningless.
2.1 The great principle: it's a hash, not encryption
Store passwords by "hashing." Not "encryption." The difference is decisive.
- Encryption can be reversed with a key (decrypted). If the key leaks, it returns to plaintext.
- A hash is one-way and irreversible. At login time, you "hash the input by the same procedure and check whether it matches the stored hash."
In the official words—even if the DB is stolen, the thief gets only hashes. They don't get plaintext passwords, and you also prevent reuse attacks against other services. This is why "storing plaintext / storing with reversible encryption" must absolutely never be done.
2.2 Hash with pwdlib (Argon2)
What the official docs currently recommend is pwdlib (not the old passlib). The algorithm is Argon2 (a modern function designed specifically for password hashing).
pip install "pwdlib[argon2]"
from pwdlib import PasswordHash
# recommended() は安全な既定(Argon2)を選んでくれる。
# pwdlib は bcrypt も使えるが、レガシー(古い)ハッシュ互換が要るときだけ passlib を使う。
password_hash = PasswordHash.recommended()
def get_password_hash(password: str) -> str:
# 登録時:平文を受け取って即ハッシュ化。平文はここから先へ持ち出さない。
return password_hash.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
# ログイン時:入力とDBのハッシュを照合(定数時間比較で行われる)。
return password_hash.verify(plain_password, hashed_password)
Why Argon2 / bcrypt (the reason SHA-256 is no good): a general-purpose hash like
sha256(password)is too fast and gets brute-forced on a GPU. Argon2 and bcrypt are designed to be intentionally slow and memory-hungry, jacking up the cost of brute force. Moreover they incorporate a salt, so the same password yields a different hash each time, also nullifying rainbow-table attacks. "Hash a password withhashlib" is wrong in production.
2.3 The user model: don't let plaintext exist in the type
Split the model into input, public, and DB storage. Don't include hashed_password in the public model—this is leak prevention by type.
from pydantic import BaseModel
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
# DB から読むときだけ存在するフィールド。API レスポンス(User)には現れない。
hashed_password: str
Let get_user(...) be a function returning UserInDB from the DB. In production, fetch from the DB (SQLAlchemy/SQLModel, etc.) and store the hash in the DB (the dummy dict in the code is just for learning. For implementation, see the typed SQLAlchemy 2 ORM).
3. Issue a JWT: PyJWT
Use a JWT (JSON Web Token) as the token's substance. A JWT is "tamper-detectable JSON signed by the server." Because the server holds the key, it can verify validity without querying the DB (stateless).
The library the official docs currently use is PyJWT (not the old python-jose).
pip install pyjwt
# RSA/ECDSA(RS256など非対称鍵)を使うなら cryptography も: pip install "pyjwt[crypto]"
3.1 Keys and constants (secrets to environment variables)
# 署名鍵は十分なエントロピーで生成する。コードに書かない。
openssl rand -hex 32
# 例: 09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
The official tutorial writes SECRET_KEY directly in the code, but that's for demo. In production, read it from an environment variable. Consolidating settings in pydantic-settings is the standard (type-safe, single source of truth).
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
secret_key: str # 環境変数 SECRET_KEY(openssl rand -hex 32)
access_token_expire_minutes: int = 30
model_config = SettingsConfigDict(env_file=".env")
settings = Settings() # 環境変数/.env から読み込む。鍵はここにしか存在しない。
ALGORITHM = "HS256" # 対称鍵署名。単一アプリならこれで十分(後述:マルチサービスは RS256)
3.2 Create the access token
from datetime import datetime, timedelta, timezone
import jwt # PyJWT
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
# exp は「timezone-aware な UTC」で入れる。naive datetime は事故の元。
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
# 署名鍵で署名する。これにより改ざんは検知可能になる(中身は"暗号化"ではなく"署名")。
return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
A JWT is not encryption: a JWT's payload (claims) can be read by anyone who Base64URL-decodes it. The signature guarantees "it hasn't been tampered with" but doesn't "keep the contents secret." You must not put passwords, personal information, or secrets in the payload. What's OK to put is about "a user identifier, permissions, and an expiry."
3.3 Make sub (subject) "a string unique across the whole app"
What indicates "whose" the token is, is the sub (subject) claim. The official guidance is clear.
subis an identifier unique across the whole app.- It must be a string.
If your design could have user IDs and group IDs collide, separate the namespace with a prefix—e.g., "username:johndoe". Avoid "an email address in sub" since it can change; an immutable ID is safe.
# 例:ユーザーの一意IDを文字列化して sub に入れる
access_token = create_access_token(data={"sub": f"username:{user.username}"})
3.4 The token response model
The shape /token returns is decided by the OAuth2 spec. It's JSON containing access_token and token_type ("bearer").
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
# トークンから復元する「検証済みの中身」。usernameは None 許容にしておく。
username: str | None = None
[Most important, security] Always specify
algorithms=[...]at verification time: specify likejwt.decode(token, key, algorithms=["HS256"]), explicitly fixing the accepted algorithms. Neglect this and an attacker can rewrite the token'salgheader tonone(no signature), or an algorithm-confusion attack of using a public key as a secret key can hold. You must not trust thealginside the token—the accepted algorithms are something the server decides. We use this in the next chapter.
4. The /token endpoint: OAuth2PasswordRequestForm
Now make the entry that "logs in and issues a token." In the OAuth2 password flow, credentials are sent not as JSON but as a form (application/x-www-form-urlencoded). FastAPI receives this with OAuth2PasswordRequestForm.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
app = FastAPI()
The fields OAuth2PasswordRequestForm provides:
username(required)password(required)scope(optional): a space-separated string. On the instance it can be referenced asscopes(a list) (used in Chapter 6).grant_type/client_id/client_secret(optional)
4.1 An authentication function that prevents timing attacks
Here is an important consideration the official docs recently added. Even when "the user doesn't exist," run a verification against a dummy hash—so the response time doesn't change based on "whether the username actually exists," preventing username enumeration.
DUMMY_HASH = password_hash.hash("a-dummy-password-for-constant-time")
def authenticate_user(username: str, password: str) -> UserInDB | None:
user = get_user(username) # DB から UserInDB | None
if user is None:
# ユーザーが居なくても必ず検証を走らせ、応答時間を一定に保つ(タイミング攻撃対策)
password_hash.verify(password, DUMMY_HASH)
return None
if not verify_password(password, user.hashed_password):
return None
return user
4.2 The login path operation
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(form_data.username, form_data.password)
if not user:
# 「ユーザー名が違う」「パスワードが違う」を区別して返さない(情報を与えない)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="ユーザー名またはパスワードが正しくありません",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data={"sub": f"username:{user.username}"},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)
return Token(access_token=access_token, token_type="bearer")
Don't leak information in error messages: returning "that user doesn't exist" teaches an attacker a valid username. Return both a wrong username and a wrong password with the same 401 and the same wording—the iron rule. Combined with 4.1's dummy hash, it's a double guard that doesn't leak the "existence judgment."
With this, sending correct credentials to /token returns a signed JWT. You can try it from /docs's "Authorize" too.
5. Verify the token and get the "current user"
This is the heart of authentication. Make a dependency that verifies the received Bearer token, restores it to a User, and injects it into the handler. Use Chapter 1's oauth2_scheme as a sub-dependency.
from jwt.exceptions import InvalidTokenError
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], # ヘッダから取れたトークン文字列
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="資格情報を検証できませんでした",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# algorithms を明示(第3.4章の必須対策)。署名・exp が自動検証される。
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
subject = payload.get("sub")
if subject is None:
raise credentials_exception
username = subject.removeprefix("username:") # sub の名前空間を外す
token_data = TokenData(username=username)
except InvalidTokenError:
# 署名不正・期限切れ・改ざん・alg不一致などはすべてここに落ちる
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
return user
jwt.decode automatically verifies exp (expiry) and throws InvalidTokenError (the derived ExpiredSignatureError) if expired. Signature mismatch, tampering, and alg mismatch are likewise rejected here.
5.1 One more step: shut out disabled users
Layer a dependency that shuts out the case "the token is valid, but the account is suspended." Dependencies can be composed as much as you like—this is FastAPI's strength.
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if current_user.disabled:
raise HTTPException(status_code=400, detail="無効化されたユーザーです")
return current_user
# 型エイリアスに畳んで再利用(公式推奨の Annotated パターン)
CurrentUser = Annotated[User, Depends(get_current_active_user)]
@app.get("/users/me", response_model=User)
async def read_users_me(current_user: CurrentUser) -> User:
# 認証ロジックはハンドラに一切ない。current_user は"検証済み"で渡ってくる。
return current_user
The return of DI: because authentication is confined in a "once-written dependency," thousands of endpoints can reuse the same mechanism. Each handler just writes
current_user: CurrentUser. The cross-cutting concern (authentication) is completely separated from business logic (SRP / DRY). In the lumber-distribution SaaS, with this pattern I protected all 221 endpoints with consistent authentication and authorization.
6. Authorization: express fine-grained permissions with Security scopes
From here is authorization (AuthZ). "Being logged in" and "may do this operation" are different things—what fills that gap is OAuth2 scopes. A scope is a string representing a permission like "me (read my own info)" or "items (read items)."
6.1 Declare scopes
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="token",
# scopes は {スコープ名: 説明} の dict。/docs にチェックボックスとして表示される。
scopes={"me": "自分自身の情報を読む", "items": "アイテムを読む"},
)
6.2 Security and SecurityScopes: this is the crux
The official's most important point: when requiring scopes, use Security, not Depends. Using Security, FastAPI understands "this endpoint requires these scopes" and reflects it in OpenAPI (/docs) too.
And what receives, on the dependency side, "which scopes are currently required" is SecurityScopes.
from fastapi import Security
from fastapi.security import SecurityScopes
from pydantic import ValidationError
class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = [] # トークンが持つスコープ
async def get_current_user(
security_scopes: SecurityScopes, # ← 要求されたスコープ群を受け取る
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
# 失敗時の WWW-Authenticate ヘッダに、必要なスコープを載せる(OAuth2準拠)
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = "Bearer"
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="資格情報を検証できませんでした",
headers={"WWW-Authenticate": authenticate_value},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
subject = payload.get("sub")
if subject is None:
raise credentials_exception
username = subject.removeprefix("username:")
token_scopes = payload.get("scope", "").split() # "scope" claim は空白区切り
token_data = TokenData(scopes=token_scopes, username=username)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
# 要求された全スコープを、このトークンが持っているか検証する
for scope in security_scopes.scopes:
if scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="権限が不足しています",
headers={"WWW-Authenticate": authenticate_value},
)
return user
6.3 Burn the scopes into the token (the /token side)
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="ユーザー名またはパスワードが正しくありません")
# ⚠️ 本番の肝:要求スコープを鵜呑みにせず「ユーザーが実際に許可されたスコープ」と突き合わせる
granted = sorted(set(form_data.scopes) & set(user_allowed_scopes(user)))
access_token = create_access_token(
data={"sub": f"username:{user.username}", "scope": " ".join(granted)},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)
return Token(access_token=access_token, token_type="bearer")
Production reinforcement over the official tutorial: the official example puts
form_data.scopesinto the token as-is (for demo). Never do that in production. Burn in only the intersection of the scopes the client requested and the permissions the user truly has (user_allowed_scopes(user)derived from DB roles, etc.). Otherwise, a regular user could self-declare anadminscope and put it in the token. The source of permissions is the server (DB), not the client's declaration.
6.4 Require scopes at the endpoint
# 「自分の情報」: me スコープが要る
@app.get("/users/me", response_model=User)
async def read_users_me(
current_user: Annotated[User, Security(get_current_active_user, scopes=["me"])],
) -> User:
return current_user
# 「自分のアイテム」: items スコープが要る(依存ツリーで me も累積される)
@app.get("/users/me/items/")
async def read_own_items(
current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])],
):
return [{"item_id": "Foo", "owner": current_user.username}]
Scopes accumulate along the dependency tree. Calling read_own_items, security_scopes.scopes contains both ["items", "me"] (the path operation's items and the me that get_current_active_user requires), and it won't pass unless both are satisfied.
6.5 Application: centralize authorization at the router layer (DRY)
Writing Security(...) per endpoint is dangerous in terms of visibility and oversights. Place it on the APIRouter's dependencies, and authorization takes effect on all routes under it.
from fastapi import APIRouter
# /admin 配下の全エンドポイントに admin スコープを強制する(書き忘れが構造的に起きない)
admin_router = APIRouter(
prefix="/admin",
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
@admin_router.get("/reports") # ← ここに認可を書かなくても admin が要求される
async def list_reports():
...
app.include_router(admin_router)
In the lumber-distribution SaaS, I centralized industry-based authorization at the router layer. I defined industry codes as a per-function frozenset whitelist, and a mismatch returns 403 (suppressing ID enumeration attacks). Further, for cross-company search I limited it to a PII-excluded public schema (UserPublic), laying down a two-layer schema boundary so other companies' emails, phones, and corporate numbers don't leak. The production quality is to protect authorization with structure centralized at the boundary, not "good-faith if statements in each handler" (multi-tenant data isolation and authorization design here).
7. Production hardening: the "beyond" of the official tutorial
By now "working authentication" is complete. From here is the diff to make it authentication that doesn't fall over and doesn't leak.
7.1 Make the access token short-lived, and use a refresh token for long life
Because a JWT is stateless, you can't do "immediate revocation." A token once issued can't be canceled from the server until exp arrives—this is the biggest weakness. So in production, split into two kinds of tokens.
| Access token | Refresh token | |
|---|---|---|
| Format | JWT (stateless) | Opaque string (server-stored) |
| Lifetime | Short (5–15 min) | Long (days–weeks) |
| Revocation | Can't (allowed by being short-lived) | Can (delete from the DB) |
| Use | On each API call | Only for reissuing the access token |
On the broadcaster platform too, what I passed to each tool was a short-lived token with a 10-minute access token and a 1-hour ID token, controlling revocation with per-device sessions. Even if it leaks, the shorter the lifetime, the smaller the damage—this is the basic philosophy of token design.
from fastapi import Form
@app.post("/token/refresh")
async def refresh(refresh_token: Annotated[str, Form()]) -> Token:
# リフレッシュトークンはDBで検証する(=即時失効できる)。
session = lookup_refresh_token(refresh_token) # 無効/失効済みなら None
if session is None:
raise HTTPException(status_code=401, detail="再ログインが必要です")
rotate_refresh_token(session) # 使ったら作り直す(盗用検知=ローテーション)
new_access = create_access_token(data={"sub": f"username:{session.username}"})
return Token(access_token=new_access, token_type="bearer")
Logout and "sign out from all devices" are achieved by deleting the refresh token from the DB. The access token is short-lived, so it's naturally invalidated within minutes at most (if you want stricter immediate revocation, put the token's jti on a denylist for a short time).
7.2 For multi-service, RS256 (asymmetric keys)
For a single app HS256 (symmetric key) is enough, but in a configuration where multiple services verify tokens, RS256 (sign with a private key, verify with a public key) is the standard. The advantage is not having to distribute the private key to verifiers—a verifier only needs the JWKS (public key).
# 発行(鍵を持つ認証サーバーだけ)
jwt.encode(payload, private_key, algorithm="RS256")
# 検証(各サービス。公開鍵だけでよい)。aud/iss も必ず検証する。
jwt.decode(token, public_key, algorithms=["RS256"], audience="my-api", issuer="https://issuer.example.com")
In the lumber-distribution SaaS's Cognito integration, I verified all endpoints with exactly this RS256 + JWKS, made exp/iat/iss/aud/token_use mandatory, and rejected anything other than token_use==id (the RS256 + JWKS verification implementation here, the difference between ID/access tokens here).
Verify
aud(audience) andiss(issuer): confirm the token came out "for this API, from a trustworthy issuer." Omit this and a token issued for another app could be repurposed. Make it mandatory withjwt.decode(..., audience=..., issuer=...). If clock skew worries you, give an allowed seconds withleeway.
7.3 CORS: configure it correctly if hit from a browser
If you hit the API from an SPA (a front on a different origin), you need CORS. The most common accident is the combination of allow_credentials=True and allow_origins=["*"].
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"], # ← Cookie併用時は "*" 不可。明示する
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
The official warning: when
allow_credentials=True(accompanying cookies), you can't makeallow_origins/allow_methods/allow_headers["*"]. You must enumerate them all explicitly. This is a requirement of the CORS spec. With the Bearer-header method you don't use cookies so you can relax it, but narrowing the origin is always recommended.
7.4 Where to place the token: Authorization header vs httpOnly Cookie
For browser-facing use, "where to store the token" becomes a design decision.
Authorization: Bearer(held in memory): strong against CSRF. But putting it inlocalStoragemeans it can be stolen by XSS. Holding the token in memory (a variable) and restoring it via refresh on reload is safe.- httpOnly Cookie: not readable from JS, so hard to steal via XSS. But CSRF countermeasures (SameSite=Lax/Strict, a CSRF token) are separately mandatory.
The design of "an unmanned kiosk terminal uses httpOnly Cookie + CSRF token, the operations console uses another method"—separating security boundaries per use—is also effective (I actually separated it that way in the realtime restaurant-matching project).
7.5 Protect /token from brute force
The login endpoint is a target of brute-force attacks. At minimum, put in the following.
- Rate limiting: limit attempt counts per IP / username (middleware like
slowapi, or a front-layer WAF / defense-in-depth like Cloud Armor). - Account lockout / exponential backoff: temporary lock on consecutive failures.
- A strong password policy: validate length and complexity at registration with Pydantic's
field_validator.
7.6 HTTPS is mandatory (reprise)
OAuth2 doesn't encrypt the communication. Over plaintext HTTP, both the password to /token and the token in the Authorization header can be eavesdropped. Production requires HTTPS (TLS). Also enforce HTTPS with the HSTS header.
8. Testing: swap authentication with dependency_overrides
There's no production launch without a verification path. Because FastAPI can swap dependencies wholesale, even an authenticated API can be tested deterministically.
from fastapi.testclient import TestClient
from app.main import app
from app.security import get_current_active_user
# 1) ハンドラ単体:認証を偽装して、ビジネスロジックに集中する
def fake_user() -> User:
return User(username="tester", disabled=False)
app.dependency_overrides[get_current_active_user] = fake_user
client = TestClient(app)
def test_read_me_returns_injected_user():
res = client.get("/users/me")
assert res.status_code == 200
assert res.json()["username"] == "tester"
app.dependency_overrides.clear() # テスト間の汚染を防ぐため必ずクリア
# 2) 認証フロー自体:トークン無し → 401、正しいログイン → トークン取得 → 保護APIが通る
def test_auth_flow_end_to_end():
real_client = TestClient(app)
assert real_client.get("/users/me").status_code == 401 # トークン無しは弾かれる
token = real_client.post(
"/token", data={"username": "johndoe", "password": "secret", "scope": "me"}
).json()["access_token"]
res = real_client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
Always write both kinds—(1) remove authentication with dependency_overrides to test the handler's logic fast, and (2) without overriding, test the real path of token issuance to verification end-to-end. With only the former, you miss bugs in the authentication wiring itself (a missing algorithms, etc.).
9. Summary: a production FastAPI authentication & authorization cheat sheet
A quick reference for when you're unsure.
- The map: authentication (who) and authorization (what's allowed) are different things. The OAuth2 password flow = "issue at
/token→ afterward verify withAuthorization: Bearer."fastapi.securitysupports the wiring with types. - Passwords: hash with pwdlib + Argon2 (
PasswordHash.recommended()). Not encryption, not SHA-256. Storing plaintext is strictly forbidden. passlib only for legacy compatibility. - JWT: PyJWT (
import jwt). Injwt.decode, explicitalgorithms=[...]is mandatory (preventingalg:none/ algorithm-confusion attacks).subis a unique string,expis timezone-aware,SECRET_KEYis an environment variable. Don't put secrets in the payload. - /token: receive the form with
OAuth2PasswordRequestForm. Verify against a dummy hash even when the user is absent (timing-attack countermeasure). Both wrong username and wrong password get the same 401. - Verification: compose
get_current_user→get_current_active_userwith dependencies. FoldAnnotated[User, Depends(...)]into a type alias and reuse across all endpoints. - Authorization: declare and verify scopes with
Security(≠Depends) +SecurityScopes. Don't take the requested scopes at face value; burn in only the intersection with the user's permitted scopes. Centralize authorization at the router layer. - Production: short-lived access token + refresh (JWTs can't be revoked). For multi-service, RS256 +
aud/issverification. CORS isallow_credentials×["*"]incompatible. Rate-limit/token. HTTPS mandatory. - Testing: test the handler with
dependency_overrides, and the auth flow without overriding—both.
FastAPI is a "get login working in 5 minutes" framework, but production quality is decided by boundary design. Crush the password one-way, keep the token short-lived, have the server fix the verification algorithm, always place the source of permissions on the server, and gather authorization in one place—none of it is flashy, but this accumulation creates authentication that "doesn't leak, can be traced, is easy to change."
On the in-house AI platform for a broadcaster, I designed an auth hub of per-tool short-lived JWTs with FastAPI at the core, and in the lumber-distribution SaaS, I protected all 221 endpoints with authorization and proved zero missing-authorization findings in a third-party pen test. With generative AI (Claude Code) as my partner, my approach is to build fast and cheaply, solo while guaranteeing quality with verification gates like a 4-round security audit.
"I want to add authentication/authorization to this API in FastAPI, but should I self-implement or use a managed IdP, and how should I draw the token design and permission boundaries?"—I'll accompany you end-to-end, from that decision through implementation, auditing, and operation. Feel free to reach out, even from the requirements-organizing stage.
References (official documentation)
- Security (FastAPI) — the concepts of OAuth2, OpenID Connect, and the password flow, and the tools of
fastapi.security - Security - First Steps (FastAPI) —
OAuth2PasswordBearer,Authorization: Bearer, /docs integration - Get Current User (FastAPI) — restoring the current user with a dependency
- Simple OAuth2 (FastAPI) —
OAuth2PasswordRequestFormand the shape of the token response - OAuth2 with Password (and hashing), Bearer with JWT tokens (FastAPI) — pwdlib(Argon2), PyJWT, timing-attack countermeasure, the
subguidance - OAuth2 scopes (FastAPI) —
Security,SecurityScopes, scope accumulation - CORS (FastAPI) —
CORSMiddlewareand the constraints ofallow_credentials - PyJWT documentation —
encode/decode,algorithms,aud/issverification - pwdlib documentation — Argon2/bcrypt password hashing