# FastAPI Authentication & Authorization Production Guide: Protecting an API with the OAuth2 Password Flow × JWT (PyJWT) × Security Scopes

> A guide to implementing production-quality authentication and authorization in FastAPI. Faithful to the latest official docs: pwdlib + Argon2 password hashing, JWT issuance/verification with PyJWT, the OAuth2 password flow's /token, get_current_user, and Security scopes, plus production hardening with short-lived tokens + refresh, CORS, rate limiting, HTTPS, and testing—all in real code.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, FastAPI, セキュリティ, 認証・認可, JWT, OAuth2
- URL: https://tomodahinata.com/en/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide
- Category: Python backend
- Pillar guide: https://tomodahinata.com/en/blog/fastapi-production-async-pydantic-observability-guide

## Key points

- Authentication (identity) and authorization (permissions) are different things. The OAuth2 password flow is a single stroke of '/token issues a token → afterward verify with Authorization: Bearer.' fastapi.security supports this wiring with types
- Hash passwords with pwdlib's Argon2 (PasswordHash.recommended()). It's a hash, not encryption, and storing plaintext is strictly forbidden. Use passlib only when legacy-hash compatibility is needed
- JWT is PyJWT (import jwt / HS256). Explicit algorithms=[...] in jwt.decode is mandatory (preventing alg:none / algorithm-confusion attacks). sub is a unique string, exp is timezone-aware, SECRET_KEY is an environment variable
- Authorization is Security scopes. Declare scopes with Security(...), not Depends, and verify required permissions with SecurityScopes. On failure return WWW-Authenticate: Bearer scope=...
- Production starts here: short-lived access tokens + refresh (JWTs can't be revoked), centralizing authorization at the router layer, the CORS allow_credentials × wildcard incompatibility, rate-limiting /token, mandatory HTTPS, and testing with dependency_overrides

---

"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](/case-studies/broadcaster-ai-content-platform); 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](/case-studies/lumber-industry-dx) (**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 from `python-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](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase)). 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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)).

---

## 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.

1. The client POSTs the username and password to **`/token`** in **form format.**
2. The server verifies the credentials, and on success returns a **JWT (access token).**
3. The client afterward sends, to protected endpoints, an **`Authorization: Bearer <token>`** header.
4. The server verifies the token with a dependency (`Depends`/`Security`), restores the **current user**, and injects it into the handler.
5. The token **expires** after a set time (keeping it short-lived is the iron rule).

The starting point of this wiring is `OAuth2PasswordBearer`.

```python
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 `Authorization` header, and if it's `Bearer <token>`, **extracts `token: str`.**
- If there's no header / it's not in `Bearer` format, **automatically returns `401 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.

```python
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).

```bash
pip install "pwdlib[argon2]"
```

```python
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 with `hashlib`" 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.

```python
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](/blog/sqlalchemy-2-typed-orm-production-guide)).

---

## 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`).

```bash
pip install pyjwt
# RSA/ECDSA（RS256など非対称鍵）を使うなら cryptography も:  pip install "pyjwt[crypto]"
```

### 3.1 Keys and constants (secrets to environment variables)

```bash
# 署名鍵は十分なエントロピーで生成する。コードに書かない。
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).

```python
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

```python
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.

- **`sub` is 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.

```python
# 例：ユーザーの一意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").**

```python
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 like `jwt.decode(token, key, algorithms=["HS256"])`, **explicitly fixing the accepted algorithms.** Neglect this and an attacker can rewrite the token's `alg` header to `none` (no signature), or an **algorithm-confusion attack** of using a public key as a secret key can hold. **You must not trust the `alg` inside 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`.**

```python
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 as **`scopes` (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.**

```python
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

```python
@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.**

```python
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.

```python
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

```python
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`.**

```python
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)

```python
@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.scopes` into 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 an `admin` scope 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

```python
# 「自分の情報」: 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.**

```python
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](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)).

---

## 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.

```python
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).

```python
# 発行（鍵を持つ認証サーバーだけ）
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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide), [the difference between ID/access tokens here](/blog/id-token-vs-access-token-oidc-oauth2-guide)).

> **Verify `aud` (audience) and `iss` (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 with `jwt.decode(..., audience=..., issuer=...)`. If clock skew worries you, give an allowed seconds with `leeway`.

### 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=["*"]`.**

```python
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 make `allow_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 in `localStorage` means 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](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide)).
- **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.

```python
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 with `Authorization: Bearer`." `fastapi.security` supports 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`). In `jwt.decode`, **explicit `algorithms=[...]` is mandatory** (preventing `alg:none` / algorithm-confusion attacks). `sub` is a unique string, `exp` is timezone-aware, `SECRET_KEY` is 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_user` with dependencies. Fold `Annotated[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`/`iss` verification. CORS is `allow_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)](https://fastapi.tiangolo.com/tutorial/security/) — the concepts of OAuth2, OpenID Connect, and the password flow, and the tools of `fastapi.security`
- [Security - First Steps (FastAPI)](https://fastapi.tiangolo.com/tutorial/security/first-steps/) — `OAuth2PasswordBearer`, `Authorization: Bearer`, /docs integration
- [Get Current User (FastAPI)](https://fastapi.tiangolo.com/tutorial/security/get-current-user/) — restoring the current user with a dependency
- [Simple OAuth2 (FastAPI)](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/) — `OAuth2PasswordRequestForm` and the shape of the token response
- [OAuth2 with Password (and hashing), Bearer with JWT tokens (FastAPI)](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) — pwdlib(Argon2), PyJWT, timing-attack countermeasure, the `sub` guidance
- [OAuth2 scopes (FastAPI)](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/) — `Security`, `SecurityScopes`, scope accumulation
- [CORS (FastAPI)](https://fastapi.tiangolo.com/tutorial/cors/) — `CORSMiddleware` and the constraints of `allow_credentials`
- [PyJWT documentation](https://pyjwt.readthedocs.io/) — `encode`/`decode`, `algorithms`, `aud`/`iss` verification
- [pwdlib documentation](https://frankie567.github.io/pwdlib/) — Argon2/bcrypt password hashing
