認証・認可のパターン
JWT・セッション・OAuthの仕組みを理解し、Webアプリのセキュリティ基盤を設計できるようになる
認証(Authentication)は「あなたが誰か」を確認すること、認可(Authorization)は「あなたに何が許可されているか」を判断することである。この2つは異なる概念だが混同されやすい。Webアプリのほぼすべてがこの仕組みを必要とする。
セッションベース認証
古典的な方式で、サーバー側でログイン状態を管理する:
1. ユーザーがログイン → サーバーがセッションIDを生成
2. サーバーがDB/メモリにセッション情報を保存
3. セッションIDをCookieとしてブラウザに返す
4. 以降のリクエストにCookieが自動付与される
5. サーバーがCookieのセッションIDでユーザーを特定
メリット: セッションを無効化してログアウトを確実に実行できる
デメリット: サーバーがスケールアウトした場合、セッション情報の共有が必要になる
JWTベース認証
現代のSPA・APIで広く使われる方式:
1. ユーザーがログイン → サーバーがJWTトークンを生成して返す
2. クライアントがトークンを保存(localStorage or Cookie)
3. 以降のリクエストのAuthorizationヘッダに付与
Authorization: Bearer eyJhbGci...
4. サーバーがトークンを検証(署名の確認のみ、DBアクセス不要)
5. トークン内のペイロードからユーザーIDや権限を取得
JWT(JSON Web Token)の構造: ヘッダー.ペイロード.署名 の3部からなるBase64エンコード文字列
ヘッダー: {"alg": "HS256", "typ": "JWT"}
ペイロード: {"sub": "user_01", "role": "admin", "exp": 1714000000}
署名: HMACSHA256(base64(header) + "." + base64(payload), secret)
メリット: サーバーがステートレス(セッション保存が不要)、スケールしやすい
デメリット: トークンの即時無効化が難しい(有効期限まで有効)
リフレッシュトークン
JWTの有効期限問題を解決する補助的な仕組み:
- アクセストークン: 有効期限が短い(15分〜1時間)。APIアクセスに使う
- リフレッシュトークン: 有効期限が長い(数日〜数週間)。新しいアクセストークンの取得に使う
アクセストークンが切れたらリフレッシュトークンで自動更新し、ユーザーに再ログインを求めない。
OAuth 2.0 / OIDC(ソーシャルログイン)
「Googleでログイン」「GitHubでログイン」の仕組みがOAuth 2.0とその拡張であるOIDC(OpenID Connect):
1. ユーザーが「Googleでログイン」を押す
2. アプリがGoogleの認証ページにリダイレクト
3. ユーザーがGoogleでログインして許可する
4. GoogleがアプリにコードをリダイレクトURLで返す
5. アプリがコードをトークンと交換する(サーバー間通信)
6. GoogleのIDトークンからユーザー情報を取得する
自前で認証基盤を作るより、OAuth/OIDCを使う方がセキュリティリスクを下げられる。
ロールベースアクセス制御(RBAC)
認可の実装パターン。ユーザーに「ロール(役割)」を付与し、ロールによって操作権限を制御する:
type Role = "admin" | "editor" | "viewer"
const permissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
}
JWTのペイロードにロールを含めてAPIで参照する。
Dify・n8nで使っているAPIキー認証
DifyのAPIキー(app-xxxx)やn8nのCredentialsは、まさに「APIキー認証」そのものである。
Difyのダッシュボード → 設定 → APIキーを発行 → app-xxxxxxxxxxxx
↓
n8n / Pythonスクリプト → Authorization: Bearer app-xxxxxxxxxxxx → Dify API
このAPIキーが「誰がリクエストしているか」を証明する認証トークンである。自分でWebアプリを作るとき、同じ仕組みを実装することになる。
Dify/n8nのCredentials管理と自作アプリの環境変数管理は本質的に同じ問題である。「APIキーをコードに直書きしない・安全に管理する」という原則は、Dify/n8nの設定でも、自作アプリの .env ファイルでも変わらない(b18参照)。
実装における注意点
- パスワードは 必ず bcrypt などでハッシュ化 して保存する(平文保存は絶対禁止)
- HTTPSを使わないとトークンが盗聴される
- CookieにJWTを保存する場合は
HttpOnly・Secure・SameSiteフラグを設定する - 短命なアクセストークンとリフレッシュトークンの組み合わせが現代のベストプラクティス
参考リソース
- jwt.io(https://jwt.io/)— JWTのデコード・動作確認ができるツール
- Auth.js(https://authjs.dev/)— Next.jsで認証を実装する際の事実上の標準ライブラリ
- 『OAuth 2.0 in Action』Justin Richer & Antonio Sanso(Manning)
- OWASP Authentication Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
確認クイズ
Q1. 認証(Authentication)と認可(Authorization)の違いを説明してください。
正解: 認証は「あなたが誰か」を確認すること、認可は「あなたに何が許可されているか」を判断すること
解説: ログイン処理は認証、ログイン後にどのページや操作にアクセスできるかを制御するのが認可である。混同しやすいが、設計上は明確に分けて考える必要がある。
Q2. セッションベース認証のデメリットとして正しいものはどれか。 A. トークンの即時無効化が難しい B. サーバースケールアウト時にセッション情報の共有が必要になる C. HTTPSなしで使える D. DBアクセスが不要になる
正解: B. サーバースケールアウト時にセッション情報の共有が必要になる
解説: セッション情報はサーバー側のDB/メモリに保存されるため、複数サーバーに負荷分散すると別サーバーがセッションを認識できない問題が発生する。A(即時無効化が難しい)はJWTのデメリットである。
Q3. JWTの構造として正しいものはどれか。 A. ユーザーID.パスワード.署名 B. ヘッダー.ペイロード.署名 C. セッションID.タイムスタンプ.ロール D. アクセストークン.リフレッシュトークン.有効期限
正解: B. ヘッダー.ペイロード.署名
解説: JWTはヘッダー・ペイロード・署名の3部をドット(.)でつないだBase64エンコード文字列。ペイロードにユーザーIDやロールなどの情報が含まれる。
Q4. JWTベース認証でサーバーがトークンを検証する際、なぜDBアクセスが不要なのか説明してください。
正解: JWTにはサーバーが検証できる署名が含まれており、署名を確認するだけでトークンの正当性を判断できるため
解説: セッションベースではセッションIDをDBで照合する必要があるが、JWTは署名検証のみで完結する。これがJWTをステートレスと呼ぶ理由であり、スケールしやすい根拠でもある。
Q5. リフレッシュトークンを使う目的として正しいものはどれか。 A. パスワードのハッシュ化 B. アクセストークンの有効期限が切れた際に再ログインなしで新しいトークンを取得する C. セッション情報をサーバーに保存する D. HTTPSを不要にする
正解: B. アクセストークンの有効期限が切れた際に再ログインなしで新しいトークンを取得する
解説: アクセストークンは短命(15分〜1時間)に設定してセキュリティリスクを下げ、リフレッシュトークン(数日〜数週間有効)で自動的に更新することでユーザーの利便性を維持する。
Q6. 「Googleでログイン」の仕組みに使われているプロトコルはどれか。 A. SMTP B. OAuth 2.0 / OIDC C. FTP D. WebSocket
正解: B. OAuth 2.0 / OIDC
解説: OAuth 2.0は認可の委譲フレームワーク、OIDCはOAuth 2.0を拡張した認証プロトコル。「Googleでログイン」のようなソーシャルログインはこの仕組みで動いており、自前で認証基盤を作るよりセキュリティリスクを下げられる。
Q7. RBACにおいて「editor」ロールに付与される権限として、このドキュメントの定義に基づき正しいものはどれか。 A. read のみ B. read・write C. read・write・delete D. write・delete
正解: B. read・write
解説: ドキュメントの定義では、adminが「read・write・delete」、editorが「read・write」、viewerが「read」のみ。ロールによって段階的に権限を絞るのがRBACの基本的な考え方である。
Q8. パスワードの保存方法として正しいものはどれか。 A. 平文でDBに保存する B. Base64エンコードして保存する C. bcryptなどでハッシュ化して保存する D. AES暗号化して保存する
正解: C. bcryptなどでハッシュ化して保存する
解説: パスワードは必ずbcryptなどの一方向ハッシュ関数で変換してから保存する。平文やBase64は論外で、AES暗号化は複合可能なため漏洩時のリスクがある。bcryptはブルートフォース攻撃への耐性も持つ。
Q9. CookieにJWTを保存する際に設定すべきフラグを3つ説明してください。
正解: HttpOnly・Secure・SameSite
解説: HttpOnlyはJavaScriptからCookieを読めなくしてXSSを防ぐ。SecureはHTTPS接続時のみCookieを送信するよう制限する。SameSiteはクロスサイトリクエストでのCookie送信を制限してCSRF攻撃を防ぐ。
Q10. DifyのAPIキー(app-xxxx)とn8nのCredentialsは、Webアプリの自作における何に相当するか。
正解: 環境変数(.envファイル)で管理するAPIキー認証トークン
解説: Dify/n8nのAPIキー管理も、自作アプリの.envによる環境変数管理も「APIキーをコードに直書きせず安全に管理する」という同じ問題を解いている。原則に変わりはなく、仕組みの本質を理解することが重要である。
実装編:本カリキュラムでの具体コード
ここから先は Week 3 / Week 14 の実装で使う具体コードです。本カリキュラムでは bcryptjs と jose(どちらも純粋 JavaScript 実装で Windows Git Bash でも動く)を採用します。
bcrypt でパスワードをハッシュ化
cd api && pnpm add bcryptjs && pnpm add -D @types/bcryptjs
api/src/lib/password.ts:
import bcrypt from "bcryptjs";
// cost=10 は 2026 時点の推奨値。約 60ms 程度
const SALT_ROUNDS = 10;
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, SALT_ROUNDS);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
登録時と照合時の使い方:
// 登録
const passwordHash = await hashPassword("S3cureP@ss!");
await db.insert(users).values({ email, passwordHash });
// ログイン照合
const [user] = await db.select().from(users).where(eq(users.email, email));
if (!user || !(await verifyPassword(inputPassword, user.passwordHash))) {
throw new UnauthorizedError("メールアドレスまたはパスワードが違います");
}
重要: verifyPassword は user === undefined の場合でも 同じ時間 かけて処理することで、タイミング攻撃で「メールアドレスが存在するか」がバレるのを防ぎます(上記コードは !user 判定を先にしているため、厳密には改善余地あり。本気でやるならダミーハッシュに対しても compare を走らせる)。
jose でアクセストークン + リフレッシュトークン
cd api && pnpm add jose
api/src/lib/auth.ts:
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
const encoder = new TextEncoder();
const ACCESS_SECRET = encoder.encode(process.env.JWT_ACCESS_SECRET ?? "");
const REFRESH_SECRET = encoder.encode(process.env.JWT_REFRESH_SECRET ?? "");
if (ACCESS_SECRET.length === 0 || REFRESH_SECRET.length === 0) {
throw new Error("JWT_ACCESS_SECRET / JWT_REFRESH_SECRET が .env に未設定");
}
export type TokenClaims = JWTPayload & { userId: string; role?: string };
// 短命:15 分
export async function signAccessToken(userId: string, role?: string) {
return new SignJWT({ userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(ACCESS_SECRET);
}
// 長命:30 日
export async function signRefreshToken(userId: string) {
return new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("30d")
.sign(REFRESH_SECRET);
}
export async function verifyAccessToken(token: string): Promise<TokenClaims> {
const { payload } = await jwtVerify<TokenClaims>(token, ACCESS_SECRET);
return payload;
}
export async function verifyRefreshToken(token: string): Promise<TokenClaims> {
const { payload } = await jwtVerify<TokenClaims>(token, REFRESH_SECRET);
return payload;
}
.env に 2 つの秘密鍵を分けて置く(同じ鍵で両方署名すると、リフレッシュトークンでアクセストークンと同じ検証を通せてしまう事故の元):
JWT_ACCESS_SECRET=長いランダム文字列1(openssl rand -hex 32 で生成)
JWT_REFRESH_SECRET=長いランダム文字列2(別物)
ログイン・リフレッシュ API
// POST /auth/login
authRouter.post("/login", zValidator("json", loginSchema), async (c) => {
const { email, password } = c.req.valid("json");
const [user] = await db.select().from(users).where(eq(users.email, email));
if (!user || !(await verifyPassword(password, user.passwordHash))) {
throw new UnauthorizedError("メールアドレスまたはパスワードが違います");
}
const accessToken = await signAccessToken(user.id, user.role);
const refreshToken = await signRefreshToken(user.id);
return c.json({ accessToken, refreshToken, user: { id: user.id, name: user.name } });
});
// POST /auth/refresh
authRouter.post("/refresh", zValidator("json", z.object({ refreshToken: z.string() })), async (c) => {
const { refreshToken } = c.req.valid("json");
try {
const claims = await verifyRefreshToken(refreshToken);
const [user] = await db.select().from(users).where(eq(users.id, claims.userId as string));
if (!user) throw new UnauthorizedError();
const accessToken = await signAccessToken(user.id, user.role);
return c.json({ accessToken });
} catch {
throw new UnauthorizedError("リフレッシュトークンが無効または期限切れ");
}
});
フロント側の保存場所:localStorage vs Cookie
| localStorage | HttpOnly Cookie | |
|---|---|---|
| XSS 耐性 | ❌ JS から読める | ✅ JS から読めない |
| CSRF 耐性 | ✅ 自動送信されない | ❌ SameSite 設定が必要 |
| モバイル | ✅ Web View でも同じコード | △ ドメイン制約で面倒 |
| 実装の楽さ | ✅ localStorage.setItem だけ | △ サーバー側設定も必要 |
本カリキュラムの方針:
- アクセストークン:
localStorage(短命なので XSS 被害も限定的) - リフレッシュトークン:
HttpOnly + Secure + SameSite=Strict Cookie(盗まれると被害が大きいので JS から触らせない)
この「ハイブリッド」方式で両方の弱点を緩和します。
フロント側:
// web/src/lib/auth.ts
export async function login(email: string, password: string) {
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include", // Cookie を受け取る/送るため
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("login failed");
const { accessToken, user } = await res.json();
localStorage.setItem("accessToken", accessToken);
return user;
}
export function getAccessToken() {
return localStorage.getItem("accessToken");
}
トークンの即時無効化(Blocklist)
JWT は「有効期限まで無効化できない」のが最大の弱点です。業務で必要になったら:
- Redis などにトークンの jti(識別子)を Blocklist として登録
verifyAccessTokenの後にawait redis.exists(jti)をチェック- パスワード変更・強制ログアウト時に該当トークンを Blocklist に追加
このやり方は「ステートレス」という JWT の利点を部分的に手放します。本カリキュラムでは導入しませんが、概念として覚えておく価値があります。
参考リソース
- jwt.io(https://jwt.io/)— JWTのデコード・動作確認ができるツール
- Auth.js(https://authjs.dev/)— Next.jsで認証を実装する際の事実上の標準ライブラリ
- jose 公式: https://github.com/panva/jose
- bcryptjs: https://github.com/dcodeIO/bcrypt.js
- 『OAuth 2.0 in Action』Justin Richer & Antonio Sanso(Manning)
- OWASP Authentication Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- 関連教材: b14-error-handling(UnauthorizedError の扱い)/ b12b-form-validation(ログインフォーム実装)
生きているコード
本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。
- 対応 Week: W3 / W14
- 参照ブランチ:
- W3:
reference/week-3 - W14:
reference/week-14 - 対応 checklist 項目: M9, M10, M11
ブランチの作り方・見方は b00-curriculum-map を参照してください。
- 1. 📄Web とは何か
- 2. 📄URL を打ってから画面が表示されるまで
- 3. 📄ネットワーク基礎(TCP/IP・DNS・HTTPS)
- 4. 📄【発展】物理層から通信が成立するまで(電力・Ethernet・Wi-Fi・Bluetooth)
- 5. 📄WSL2・Docker セットアップ詳細(Windows 向け)
- 6. 📄環境構築の段階的導入(macOS / Windows)
- 7. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
- 8. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
- 9. 📄シェル・ターミナル基礎
- 10. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
- 11. 📄Git基礎
- 12. 📄GitHubワークフロー
- 13. 📄パッケージ管理(pnpm workspace)
- 1. 📄AWSインフラ基礎
- 2. 📄AWS Budget Alert の設定(Month 5 Week 17)
- 3. 📄環境変数管理
- 4. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
- 5. 📄CI/CD基礎
- 6. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
- 7. 📄テスト設計の基本
- 8. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
- 9. 📄CLAUDE.md・プロジェクト設定
- 10. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
- 11. 📄タスク分解・仕様の書き方
- 12. 📄Playwright で E2E テスト(Month 6 Week 22)
- 13. 📄生成コードのレビュー・デバッグの勘所
- 14. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
- 15. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
- 16. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)