概要
この記事の構成
- バックエンド
- FastAPIでJWT + Cookie認証を実装
- curlで動作確認
- フロントエンド
- ReactからAPIを叩く
- Vite proxy
- 簡単なUI実装
- ReactからAPIを叩く
前提知識
- web, フロントエンド(react)
- fastAPI, pydantic, posgreSQL, SQL Alchemy, alembic(下記のブログで開設済み)
- こちらの前回の記事の続き
- よろしければ事前にお読みください
- git hub
この記事でやること
- JWT + HttpOnly Cookieの仕組みを理解する
- FastAPIで認証機構を一から実装
- フロントエンドからログイン・ログアウト機能の実装
技術スタック
- FastAPI
- PostgreSQL(Neon)
- SQLAlchemy
- Alembic
- JWT(PyJWT)
- bcrypt
- vite + react
解説
認証の全体像
- ログイン時
- ユーザー名・パスワードを受け取る
- DBでユーザーを取得
- パスワードを検証
- JWTを生成
- Cookieに入れて返す
- 以降のリクエスト
- Cookieが自動送信される
- JWTを検証
- ユーザーを特定
JWTとは
- JSONデータに署名をつけたもの
- 改ざんできない
- サーバー側にセッションを持たなくてよい
中身のイメージ:
{
"sub":"1",
"username":"alice",
"exp":9999999999
}
Cookieとは
- ブラウザが自動で送るデータ
- サーバーが
Set-Cookieで設定する
今回の用途:
- JWTを保存する
HttpOnly Cookieを使う理由
- JavaScriptからアクセスできない
- XSS耐性が上がる
- localStorageより安全
パスワードの扱い
- 平文では保存しない
- bcryptでハッシュ化する
処理:
- register時: hashして保存
- login時: hashを比較
JWTの生成と検証
- login時にJWTを生成
- Cookieに格納する
- API呼び出し時
- CookieからJWTを取得
- decodeして検証
- user_idを取り出す
実装
- 載っていないコード, 実行環境(IaC)は前回の記事・git hubを参照
バックエンド
- .infra/.env
- JWTの設定
...
jwt_secret_key=set_your_secret_key
jwt_algorithm=HS256
access_token_expire_minutes=60
# cookie_domain=
- requirements.txt
fastapi
uvicorn[standard]
sqlalchemy
alembic
psycopg[binary]
pydantic
pydantic-settings
bcrypt
PyJWT
- app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# mode
production: bool
# DB
database_url: str
database_url_direct: str
# JWT
jwt_secret_key: str
jwt_algorithm: str
access_token_expire_minutes: int
# Cookie
cookie_domain: str | None = None
@property
def cookie_secure(self) -> bool:
return self.production
@property
def cookie_samesite(self) -> str:
# 将来クロスドメインになるなら None
return "strict" if self.production else "lax"
model_config = SettingsConfigDict(
# env_file=".env", # .envファイルなどで環境変数を設定する場合はこれを使う
# 今回はOSにあらかじめ環境変数がある前提(docker-compose.yaml+.envで設定)
extra="ignore",
)
settings = Settings()
- app/models.py
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
...
# 認証用
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(
String(50),
unique=True,
index=True,
nullable=False,
)
password_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
- app/schemas.py
...
# 認証関係
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=100)
class UserLogin(BaseModel):
username: str
password: str
class UserRead(BaseModel):
id: int
username: str
model_config = ConfigDict(from_attributes=True)
- app/secrity.py
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from jwt.exceptions import InvalidTokenError
from app.config import settings
# パスワード
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
# JWT
def create_access_token(user_id: int, username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {
"sub": str(user_id),
"username": username,
"exp": expire,
}
return jwt.encode(
payload,
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm,
)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(
token,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
except InvalidTokenError:
raise ValueError("Invalid token")
- app/main.py
from fastapi import Depends, FastAPI, HTTPException, Response, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.db import SessionLocal
from app.models import Note, User
from app.schemas import NoteListItem, NoteRead, UserCreate, UserLogin, UserRead
from app.security import create_access_token, hash_password, verify_password
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/api/notes", response_model=list[NoteListItem])
def list_notes(db: Session = Depends(get_db)):
notes = db.execute(select(Note)).scalars().all()
# SQLAlchemy Note -> NoteRead は明示変換
note_reads = [NoteRead.model_validate(note) for note in notes]
# NoteRead -> NoteListItem は FastAPI + Pydantic に任せる
return note_reads
# 認証関係
# 後でフロント直叩きも試せるように入れておく
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
domain=settings.cookie_domain,
max_age=settings.access_token_expire_minutes * 60,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(
key="access_token",
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
domain=settings.cookie_domain,
path="/",
)
@app.post(
"/api/register",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.username == user_in.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
user = User(
username=user_in.username,
password_hash=hash_password(user_in.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@app.post("/api/login")
def login(user_in: UserLogin, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == user_in.username).first()
if user is None or not verify_password(user_in.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
token = create_access_token(user.id, user.username)
set_auth_cookie(response, token)
return {"message": "Logged in"}
@app.post("/api/logout")
def logout(response: Response):
clear_auth_cookie(response)
return {"message": "Logged out"}
@app.get("/api/me", response_model=UserRead)
def me(current_user: User = Depends(get_current_user)):
return current_user
curlでの動作確認
フロントエンドを作る前に、まずバックエンド単体で確認する
ユーザー登録
curl-X POST http://localhost:8000/api/register \
-H"Content-Type: application/json" \
-d'{"username":"alice","password":"password123"}'
# response
HTTP/1.1 200 OK
date: Tue, 21 Apr 2026 10:57:53 GMT
server: uvicorn
content-length: 23
content-type: application/json
set-cookie: access_token=eyJhXXX.eyXXX.jLPXXX; # 本来はもっと長い, アルゴリズム次第
HttpOnly;
Max-Age=3600;
Path=/; SameSite=lax
ログイン(Cookie保存)
curl-i-X POST http://localhost:8000/api/login \
-H"Content-Type: application/json" \
-d'{"username":"alice","password":"password123"}' \
-c cookies.txt
確認ポイント:
- Set-Cookie が返る
- cookies.txt に access_token が保存される
-
cat cookies.txtで見れる
-
ログインユーザー取得
curl http://localhost:8000/api/me \
-b cookies.txt
# reaponse
HTTP/1.1 200 OK
date: Tue, 21 Apr 2026 11:00:21 GMT
server: uvicorn
content-length: 27
content-type: application/json
{"id":1,"username":"alice"}
ログアウト
curl-X POST http://localhost:8000/api/logout \
-b cookies.txt \
-c cookies.txt
# response
{"message":"Logged out"}
ログアウト後
curl http://localhost:8000/api/me -b cookies.txt
# response
{"message":"Logged out"}
# after logout
curl http://localhost:8000/api/me -b cookies.txt
{"detail":"Not authenticated"}
- 401が返れば成功
ここまでで分かること
- JWTの役割
- Cookieの挙動
- FastAPIでの認証の組み込み方
- サーバーだけで認証フローを検証できる
フロントエンド
設定
- 後日追記します。