1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + FastAPIでJWT認証を実装してみた

1
Posted at

概要

この記事の構成

  • バックエンド
    • FastAPIでJWT + Cookie認証を実装
    • curlで動作確認
  • フロントエンド
    • ReactからAPIを叩く
      • Vite proxy
    • 簡単なUI実装

前提知識


この記事でやること

  • 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での認証の組み込み方
  • サーバーだけで認証フローを検証できる

フロントエンド

設定

  • 後日追記します。
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?