authentication-patterns について
authentication-patterns は、Web・モバイルアプリの認証と認可を実装する開発者向けスキルです。OAuth、トークン、多要素認証、SSO、セッション、アクセス制御の手法を、安全原則や実装例とともに扱います。
主な機能
- 01OAuth 2.0 と PKCE のフロー
- 02JWT とセッション管理
- 03TOTP と WebAuthn による MFA
- 04SAML と OIDC による SSO
- 05RBAC と ABAC による認可
- 06セキュリティチェックリストとアンチパターン
活用シーン
- 01ソーシャルログインや登録フローの追加
- 02トークンローテーションによる API アクセス保護
- 03機密性の高いアカウントへの MFA 導入
- 04アプリ内リソースの権限定義
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill authentication-patternsインストール後、Claude Code で/authentication-patternsを入力して読み込まれたか確認します。
詳細
認証パターン
概要
このスキルでは、Web アプリケーションとモバイルアプリケーションにおける認証および認可の実装を扱います。OAuth 2.0 フロー(Authorization Code with PKCE、Client Credentials)、JWT 管理(アクセストークン、リフレッシュトークン、ローテーション)、セッション管理戦略、多要素認証(TOTP、WebAuthn/パスキー)、認証ライブラリ(NextAuth/Auth.js v5、Clerk、Supabase Auth、Lucia)との連携、SSO プロトコル(SAML、OIDC)、認可パターン(RBAC、ABAC)を取り上げます。
ログイン/登録フローの構築、ソーシャルログインプロバイダーとの連携、MFA の実装、エンタープライズ顧客向けの SSO の設定、認可モデルの設計、認証プロバイダー間の移行を行う際に、このスキルを使用してください。
基本原則
- 暗号処理を独自に実装しない - パスワードのハッシュ化(bcrypt、argon2)、JWT の署名、OAuth フローには、実績のあるライブラリを使用してください。カスタム認証コードは、Web アプリケーションにおけるセキュリティ脆弱性の最大の原因です。
- 多層防御 - 認証は単一のチェックではありません。セッション検証、CSRF 保護、レート制限、異常検知を重ねてください。各層は個別に回避される可能性があると想定してください。
- トークンは認証情報です - アクセストークン、リフレッシュトークン、セッションクッキーは安全に保存し(httpOnly クッキー、暗号化ストレージ)、HTTPS 経由でのみ送信し、定期的にローテーションする必要があります。
- デフォルトで最小権限にする - ユーザーと API クライアントには、最小限の権限から付与してください。アクセス権は明示的なロール割り当てによって昇格させ、暗黙の信頼によって付与してはいけません。
- アカウント復旧を計画する - パスワードリセット、MFA 復旧コード、メールアドレスの確認、アカウントロックアウトには、それぞれフローの設計が必要です。これらは通常のログインフローより複雑です。
主なパターン
パターン 1:OAuth とデータベースセッションを使用する NextAuth(Auth.js v5)
使用する場面: ソーシャルログイン、メールアドレス/パスワード認証、またはマジックリンク認証とサーバーサイドのセッション管理が必要な Next.js アプリケーション。
実装:
// auth.ts - Auth.js v5 configuration
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.passwordHash) return null;
const valid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
session: {
strategy: "database", // Server-side sessions (not JWT)
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Refresh session every 24 hours
},
callbacks: {
async session({ session, user }) {
// Add user role to session
session.user.id = user.id;
session.user.role = user.role;
return session;
},
async signIn({ user, account }) {
// Block sign-in for disabled accounts
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
});
if (dbUser?.disabled) return false;
}
return true;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
// Middleware for route protection
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/register");
const isDashboard = req.nextUrl.pathname.startsWith("/dashboard");
if (isDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/login", "/register"],
};
理由: Auth.js v5 は、OAuth の複雑な処理(state パラメーター、PKCE、トークン交換)、セッション管理、CSRF 保護、プロバイダー固有の仕様への対応を担います。データベースセッションは、即座に失効でき、クライアントにクレームを公開しないため、JWT セッションより安全です。
パターン 2:JWT アクセストークン/リフレッシュトークンパターン
使用する場面: ステートレスな検証が必要な SPA、モバイルアプリ、またはマイクロサービス間通信における API 認証。
実装:
// Token generation
import jwt from "jsonwebtoken";
import { randomBytes } from "crypto";
interface TokenPayload {
sub: string; // User ID
email: string;
role: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
const ACCESS_TOKEN_EXPIRY = "15m";
const REFRESH_TOKEN_EXPIRY = "7d";
function generateTokenPair(user: TokenPayload): TokenPair {
const accessToken = jwt.sign(
{ sub: user.sub, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: "myapp",
audience: "myapp-api",
}
);
// Refresh token is opaque (not JWT) - stored server-side
const refreshToken = randomBytes(64).toString("hex");
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
// Token refresh endpoint
async function refreshTokens(refreshToken: string): Promise<TokenPair> {
// 1. Look up refresh token in database
const stored = await db.refreshToken.findUnique({
where: { token: hashToken(refreshToken) },
include: { user: true },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedError("Invalid or expired refresh token");
}
// 2. Rotate refresh token (invalidate old, create new)
await db.refreshToken.delete({ where: { id: stored.id } });
const newPair = generateTokenPair({
sub: stored.user.id,
email: stored.user.email,
role: stored.user.role,
});
// 3. Store new refresh token
await db.refreshToken.create({
data: {
token: hashToken(newPair.refreshToken),
userId: stored.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return newPair;
}
// Token verification middleware
function verifyAccessToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: "myapp",
audience: "myapp-api",
});
return decoded as TokenPayload;
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw new UnauthorizedError("Access token expired");
}
throw new UnauthorizedError("Invalid access token");
}
}
// Secure cookie-based token delivery (for web apps)
function setAuthCookies(res: Response, tokens: TokenPair): void {
// Access token in httpOnly cookie
res.headers.append(
"Set-Cookie",
`access_token=${tokens.accessToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${tokens.expiresIn}`
);
// Refresh token in httpOnly cookie with restricted path
res.headers.append(
"Set-Cookie",
`refresh_token=${tokens.refreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=${7 * 24 * 60 * 60}`
);
}
理由: 有効期間の短いアクセストークン(15 分)を使うと、トークンが盗まれた場合の被害期間を限定できます。サーバー側に保存する不透明なリフレッシュトークンは、JWT と異なり即座に失効できます。リフレッシュトークンのローテーションにより、トークンの盗難を検知できます。正規のユーザーがすでにローテーションした後に盗まれたリフレッシュトークンが使用された場合、トークンファミリー全体が無効化されます。
パターン 3:多要素認証(TOTP)
使用する場面: パスワードに加えて認証要素が必要な場合。特に、管理者アカウントや機密性の高い操作に適しています。
実装:
// MFA setup flow
import { authenticator } from "otplib";
import QRCode from "qrcode";
// Step 1: Generate secret and QR code for user
async function setupMFA(userId: string): Promise<{ qrCodeUrl: string; secret: string }> {
const secret = authenticator.generateSecret();
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
// Store encrypted secret (not yet verified)
await db.mfaSetup.upsert({
where: { userId },
create: { userId, secret: encrypt(secret), verified: false },
update: { secret: encrypt(secret), verified: false },
});
const otpauth = authenticator.keyuri(user.email, "MyApp", secret);
const qrCodeUrl = await QRCode.toDataURL(otpauth);
return { qrCodeUrl, secret };
}
// Step 2: Verify code to complete setup
async function verifyMFASetup(userId: string, code: string): Promise<string[]> {
const setup = await db.mfaSetup.findUniqueOrThrow({
where: { userId },
});
const secret = decrypt(setup.secret);
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new ValidationError("Invalid verification code");
}
// Generate recovery codes
const recoveryCodes = Array.from({ length: 10 }, () =>
randomBytes(4).toString("hex").toUpperCase()
);
// Store hashed recovery codes
await db.$transaction([
db.mfaSetup.update({
where: { userId },
data: { verified: true },
}),
db.user.update({
where: { id: userId },
data: { mfaEnabled: true },
}),
...recoveryCodes.map((code) =>
db.recoveryCode.create({
data: { userId, codeHash: hashCode(code) },
})
),
]);
return recoveryCodes; // Show to user ONCE
}
// Step 3: Verify TOTP during login
async function verifyMFA(userId: string, code: string): Promise<boolean> {
const setup = await db.mfaSetup.findUniqueOrThrow({
where: { userId, verified: true },
});
const secret = decrypt(setup.secret);
// Check TOTP code (allows 1 window of drift)
if (authenticator.verify({ token: code, secret })) {
return true;
}
// Check recovery codes
const recoveryCodes = await db.recoveryCode.findMany({
where: { userId, used: false },
});
for (const rc of recoveryCodes) {
if (await verifyHash(code, rc.codeHash)) {
// Mark recovery code as used (one-time use)
await db.recoveryCode.update({
where: { id: rc.id },
data: { used: true, usedAt: new Date() },
});
return true;
}
}
return false;
}
理由: TOTP ベースの MFA は広くサポートされており(Google Authenticator、Authy、1Password)、SMS を必要としないため(SMS は SIM スワップの被害を受けやすい)、オフラインでも動作します。復旧コードは、ユーザーが認証アプリのデバイスを失った場合の備えになります。秘密鍵の暗号化と復旧コードのハッシュ化により、データベース侵害への対策ができます。
パターン 4:ロールベースアクセス制御(RBAC)
使用する場面: ユーザーごとにリソースへのアクセスレベルを変える必要がある場合。
実装:
// Permission definitions
const PERMISSIONS = {
// Projects
"project:read": "View project details",
"project:write": "Edit project settings",
"project:delete": "Delete projects",
"project:manage_members": "Add/remove project members",
// Billing
"billing:read": "View billing information",
"billing:write": "Manage subscriptions and payments",
// Admin
"admin:users": "Manage all users",
"admin:settings": "Manage organization settings",
} as const;
type Permission = keyof typeof PERMISSIONS;
// Role definitions
const ROLES: Record<string, Permission[]> = {
viewer: ["project:read"],
editor: ["project:read", "project:write"],
admin: [
"project:read",
"project:write",
"project:delete",
"project:manage_members",
"billing:read",
"billing:write",
],
owner: Object.keys(PERMISSIONS) as Permission[],
};
// Permission check middleware
function requirePermission(...requiredPermissions: Permission[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) {
return res.status(401).json({ error: "Authentication required" });
}
const userPermissions = ROLES[user.role] ?? [];
const hasAllPermissions = requiredPermissions.every((p) =>
userPermissions.includes(p)
);
if (!hasAllPermissions) {
return res.status(403).json({
error: "Insufficient permissions",
required: requiredPermissions,
current: user.role,
});
}
next();
};
}
// Usage in routes
app.get("/api/projects/:id", requirePermission("project:read"), getProject);
app.put("/api/projects/:id", requirePermission("project:write"), updateProject);
app.delete("/api/projects/:id", requirePermission("project:delete"), deleteProject);
// Component-level permission checks (React)
function usePermission(permission: Permission): boolean {
const { data: session } = useSession();
if (!session?.user?.role) return false;
const permissions = ROLES[session.user.role] ?? [];
return permissions.includes(permission);
}
function ProjectSettings({ project }: { project: Project }) {
const canEdit = usePermission("project:write");
const canDelete = usePermission("project:delete");
return (
<div>
<h2>Settings</h2>
{canEdit ? (
<ProjectForm project={project} />
) : (
<ProjectDetails project={project} />
)}
{canDelete && <DeleteProjectButton projectId={project.id} />}
</div>
);
}
理由: RBAC は、明確で監査可能な権限モデルを提供します。権限を定数として定義し、TypeScript を使用することで、コンパイル時の安全性を確保できます。サーバー(ミドルウェア)とクライアント(UI)の両方で権限を確認することで、多層防御を実現できます。サーバーはセキュリティを強制し、クライアントはユーザー体験を提供します。
パスワードハッシュ化のリファレンス
| アルゴリズム | 推奨度 | コスト係数 | 備考 |
|---|---|---|---|
| argon2id | 最適 | メモリ:64MB、反復回数:3 | GPU/ASIC 攻撃に対する保護が最も強力です |
| bcrypt | 良好 | ラウンド数:12-14 | 広くサポートされ、実績があります |
| scrypt | 良好 | N:2^15、r:8、p:1 | メモリ負荷が高く、argon2 の優れた代替手段です |
| PBKDF2 | 許容範囲 | 反復回数:600,000+(SHA-256) | NIST 推奨ですが、メモリ負荷は高くありません |
| MD5/SHA-256 | 使用不可 | 該当なし | パスワードハッシュではありません。処理が速すぎ、ソルトもありません |
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
| パスワードを平文で保存する | 1 回の侵害ですべてのパスワードが漏えいします | ユーザーごとのソルトを使用して argon2id または bcrypt でハッシュ化します |
| localStorage に JWT を保存する | XSS によりトークンが盗まれる可能性があります | httpOnly Secure Cookie を使用します |
| 有効期間の長いアクセストークン(数日/数週間) | 盗まれた場合、被害が長期間に及びます | 15 分間有効なアクセストークンとリフレッシュトークンのローテーションを使用します |
| クライアント側だけで権限を確認する | クライアント側のチェックは回避できます | サーバー側のミドルウェアとクライアント側の UX を組み合わせます |
| OAuth を独自に実装する | 微妙なセキュリティ上のバグ(state、PKCE、リダイレクト)が発生する可能性があります | 実績のあるライブラリ(Auth.js、Passport)を使用します |
| MFA の選択肢を SMS による 2FA のみにする | SIM スワッピングの脆弱性があります | SMS と併せて TOTP と WebAuthn を提供します |
| ログインエンドポイントにレート制限を設定しない | 総当たり攻撃を受ける可能性があります | IP アドレスとユーザー名に基づいてレート制限を設定します |
| 有効期限のないパスワードリセットトークン | トークンを無期限に使用できます | 有効期限を 1 時間にし、1 回限りの使用とし、パスワード変更時に無効化します |
チェックリスト
- パスワードは argon2id または bcrypt でハッシュ化する(平文および MD5/SHA は使用しない)
- OAuth フローではパブリッククライアント(SPA、モバイル)に PKCE を使用する
- アクセストークンの有効期間を短くする(30 分未満)
- リフレッシュトークンは使用時にローテーションし、サーバー側に保存する
- トークンは httpOnly Secure SameSite Cookie で配信する(Web)
- ログインエンドポイントにレート制限を設定する(IP アドレスとユーザー名に基づく)
- すべてのユーザーが MFA を利用できるようにし、管理者ロールには必須とする
- MFA の設定時にリカバリーコードを生成する
- RBAC 権限をサーバー(ミドルウェア)とクライアント(UX)で確認する
- パスワードリセットトークンの有効期限を 1 時間にし、1 回限りの使用とする
- N 回のログイン失敗後にアカウントをロックする(段階的に遅延を増やす)
- パスワード変更時にセッションを無効化する
関連リソース
- スキル:
application-security(OWASP 認証の脆弱性)、payment-integration(請求認証) - スキル:
email-systems(パスワードリセットメール)、product-analytics(認証時の識別/エイリアス) - ルール:
docs/reference/stacks/fullstack-nextjs-nestjs.md(NestJS 認証ガード)