- Published on
OAuth 2.0 & 認証完全ガイド2025:JWT、セッション、SSO、OIDC、Passkeyまで
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- 1. Authentication vs Authorization
- 2. セッションベース認証
- 3. トークンベース認証
- 4. JWT深層分析
- 5. OAuth 2.0フロー
- 6. OpenID Connect(OIDC)
- 7. SSO(シングルサインオン)
- 8. ソーシャルログイン実装
- 9. Passkey / WebAuthn / FIDO2
- 10. 多要素認証(MFA)
- 11. セキュリティベストプラクティス
- 12. 認証ライブラリ比較
- 13. セッション vs JWT比較表
- 14. クイズ
- 参考資料
はじめに
Webアプリケーションにおいて「認証(Authentication)」と「認可(Authorization)」はセキュリティの要です。ユーザーログインからAPIアクセス制御、マイクロサービス間通信まで、適切な認証体系なくして安全なシステムの構築はできません。
2025年現在、認証エコシステムは急速に進化しています。従来のセッションベース認証からJWTトークンベース認証、OAuth 2.0、OIDCを経てPasskeyまで、選択肢はかつてないほど多様化しています。
このガイドでは、Authentication vs Authorizationの根本的な違いからJWT深層分析、OAuth 2.0全フロー、SSO、Passkey、セキュリティベストプラクティスまで、Web認証のすべてを網羅します。
1. Authentication vs Authorization
1.1 核心的な違い
Authentication(認証) - 「あなたは誰ですか?」
- ユーザーの身元を確認するプロセス
- ログインプロセスそのもの
- 例:ID/パスワード、生体認証、OTP
Authorization(認可) - 「あなたは何ができますか?」
- 認証済みユーザーの権限を確認するプロセス
- リソースアクセス制御
- 例:管理者のみ削除可能、本人データのみ閲覧可能
1.2 認証/認可フロー
1. ユーザーがログインリクエスト(Authentication)
POST /auth/login
Body: { "email": "user@example.com", "password": "..." }
2. サーバーが資格情報を検証(Authentication)
- DBでユーザーを検索
- パスワードハッシュを比較
- 認証成功時にトークン/セッションを発行
3. APIリクエスト時にトークン/セッションを付与
GET /admin/users
Authorization: Bearer eyJhbGci...
4. サーバーが権限を確認(Authorization)
- トークンからロールを抽出
- エンドポイントへのアクセス権限を確認
- 権限あり -> 200 OK
- 権限なし -> 403 Forbidden
1.3 HTTPステータスコード
401 Unauthorized = 認証(Authentication)失敗
- 「あなたが誰かわかりません。ログインしてください。」
- トークンなし、トークン期限切れ、無効な資格情報
403 Forbidden = 認可(Authorization)失敗
- 「あなたが誰かは知っていますが、このリソースへのアクセス権限がありません。」
- 一般ユーザーが管理者APIを呼び出し
2. セッションベース認証
2.1 動作原理
1. クライアント: POST /login(メール、パスワード)
2. サーバー: 資格情報を検証
3. サーバー: セッション作成(サーバーメモリ/Redisに保存)
Session ID: "sess_abc123"
Data: { userId: 42, role: "admin", createdAt: "..." }
4. サーバー: Set-Cookieヘッダーでセッション IDを返却
Set-Cookie: session_id=sess_abc123; HttpOnly; Secure; SameSite=Strict
5. クライアント: 以降のリクエストでクッキーを自動送信
Cookie: session_id=sess_abc123
6. サーバー: セッションIDでユーザー情報を検索
2.2 Express.jsセッション実装
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPSのみで送信
httpOnly: true, // JavaScriptアクセスをブロック(XSS防止)
sameSite: 'strict', // CSRF防止
maxAge: 24 * 60 * 60 * 1000 // 24時間
}
}));
// ログイン
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: '無効な資格情報' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'ログイン成功' });
});
// 認証ミドルウェア
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: '認証が必要です' });
}
next();
}
// 認可ミドルウェア
function requireRole(role) {
return (req, res, next) => {
if (req.session.role !== role) {
return res.status(403).json({ error: '権限がありません' });
}
next();
};
}
// ログアウト
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: 'ログアウト成功' });
});
});
2.3 セッションストア比較
| ストア | メリット | デメリット | 適した用途 |
|---|---|---|---|
| メモリ | 最速 | 再起動で消失、共有不可 | 開発環境 |
| Redis | 高速、共有可能、TTL | 追加インフラ必要 | 本番環境(推奨) |
| DB (PostgreSQL) | 永続性、監査可能 | 低速 | 監査が必要な場合 |
| ファイル | 簡単な実装 | 低速、共有不可 | 小規模アプリ |
3. トークンベース認証
3.1 JWT構造
JWT = Header.Payload.Signature(Base64URLエンコード、ドットで区切り)
Header(ヘッダー):
{
"alg": "RS256", // 署名アルゴリズム
"typ": "JWT", // トークンタイプ
"kid": "key-id-1" // キー識別子(Key Rotation用)
}
Payload(ペイロード - Claims):
{
"iss": "https://auth.example.com", // Issuer(発行者)
"sub": "user-123", // Subject(主体)
"aud": "https://api.example.com", // Audience(対象)
"exp": 1711356600, // Expiration(有効期限)
"iat": 1711353000, // Issued At(発行日時)
"nbf": 1711353000, // Not Before(使用開始時刻)
"jti": "unique-token-id", // JWT ID(一意ID)
"email": "user@example.com", // カスタムクレーム
"roles": ["admin", "user"] // カスタムクレーム
}
Signature(署名):
RS256(
base64url(header) + "." + base64url(payload),
privateKey
)
3.2 署名アルゴリズム比較
| アルゴリズム | タイプ | キー | ユースケース |
|---|---|---|---|
| HS256 | 対称鍵 | 1つのシークレットキー | 単一サーバー、内部サービス |
| RS256 | 非対称鍵 | 公開鍵/秘密鍵ペア | マイクロサービス、外部検証 |
| ES256 | 非対称鍵 (ECDSA) | 短いキー長 | モバイル、IoT(性能重視) |
// HS256 - 同じキーで署名と検証
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// 署名
const token = jwt.sign({ sub: 'user-123' }, SECRET, { algorithm: 'HS256' });
// 検証(同じキーが必要)
const decoded = jwt.verify(token, SECRET);
// RS256 - 秘密鍵で署名、公開鍵で検証
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// 署名(認証サーバー)
const token = jwt.sign(
{ sub: 'user-123', roles: ['admin'] },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// 検証(APIサーバー - 公開鍵のみ必要)
const decoded = jwt.verify(token, publicKey);
3.3 JWT検証チェックリスト
1. 署名検証(Signature Verification)
- 正しいアルゴリズムで署名されているか確認
- alg: "none"攻撃を防止(必ずアルゴリズムを明示)
2. 有効期限の確認(exp)
- 現在時刻がexpより前であることを確認
- クロックスキューを考慮した若干の余裕(leeway)
3. 発行者の確認(iss)
- 信頼できる発行者であることを確認
4. 対象の確認(aud)
- このトークンが自サービス向けであることを確認
5. Not Beforeの確認(nbf)
- トークン使用開始時刻以降であることを確認
6. 追加セキュリティ検証
- トークンがブラックリストにないか確認
- ユーザーが無効化されていないか確認
4. JWT深層分析
4.1 Access TokenとRefresh Token
Access Token:
- 短い有効期間(15分~1時間)
- APIリクエスト時にAuthorizationヘッダーに含む
- 盗取されても被害期間が限定的
- サーバーに保存しない(Stateless)
Refresh Token:
- 長い有効期間(7日~30日)
- Access Tokenの更新にのみ使用
- サーバーDB/Redisに保存(Stateful)
- HttpOnlyクッキーに保存(XSS防止)
4.2 トークン更新フロー
1. 初回ログイン
POST /auth/login -> Access Token + Refresh Token発行
2. APIリクエスト
GET /api/data
Authorization: Bearer [access_token]
-> 200 OK(正常)
3. Access Token期限切れ
GET /api/data
Authorization: Bearer [expired_access_token]
-> 401 Unauthorized
4. トークン更新
POST /auth/refresh
Cookie: refresh_token=...
-> 新しいAccess Token + 新しいRefresh Token発行
5. Refresh Token期限切れ
POST /auth/refresh
-> 401 Unauthorized -> 再ログインが必要
4.3 Refresh Token Rotation
// Refresh Token Rotation実装
async function refreshTokens(refreshToken) {
// 1. DBからRefresh Tokenを検索
const storedToken = await db.findRefreshToken(refreshToken);
if (!storedToken) {
// 既に使用済みのRefresh Token -> 全トークン無効化(盗取検知)
await db.revokeAllTokensForUser(storedToken.userId);
throw new Error('Refresh Token reuse detected');
}
// 2. 有効性確認
if (storedToken.expiresAt < Date.now()) {
throw new Error('Refresh Token expired');
}
// 3. 以前のRefresh Tokenを無効化
await db.revokeRefreshToken(refreshToken);
// 4. 新しいトークンペアを発行
const newAccessToken = jwt.sign(
{ sub: storedToken.userId, roles: storedToken.roles },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
const newRefreshToken = crypto.randomUUID();
await db.saveRefreshToken({
token: newRefreshToken,
userId: storedToken.userId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
4.4 トークンブラックリスト
// Redisベースのブラックリスト
const redis = require('redis');
const client = redis.createClient();
// トークンをブラックリストに追加(ログアウト時)
async function blacklistToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
}
}
// トークン検証時にブラックリストを確認
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await client.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
return decoded;
}
4.5 JWTベストプラクティス
やるべきこと(DO):
- Access Tokenの有効期間を短く(15分)
- RS256またはES256を使用(非対称鍵)
- jtiクレームでトークンを一意に識別
- Refresh Tokenはサーバー側で管理
- 機密情報はPayloadに入れない
やってはいけないこと(DON'T):
- JWTをlocalStorageに保存(XSSに脆弱)
- HS256をマイクロサービス間で使用
- トークンにパスワード/カード番号を含める
- alg: "none"を許可
- 有効期限なしのトークン発行
5. OAuth 2.0フロー
5.1 OAuth 2.0の役割
Resource Owner(リソースオーナー):
- エンドユーザー(例:Googleアカウント所有者)
Client(クライアント):
- リソースアクセスを要求するアプリケーション(例:自分のWebアプリ)
Authorization Server(認可サーバー):
- トークンを発行するサーバー(例:Google OAuthサーバー)
Resource Server(リソースサーバー):
- 保護されたリソースをホスティングするサーバー(例:Google API)
5.2 Authorization Code Flow(+ PKCE)
最も推奨されるフロー。Webアプリ、SPA、モバイルアプリ全てで使用。
PKCE(Proof Key for Code Exchange)はSPA/モバイルで必須。
1. クライアントがPKCEパラメータを生成
code_verifier = random(43-128 chars)
code_challenge = BASE64URL(SHA256(code_verifier))
2. 認可リクエスト
GET /authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://myapp.com/callback&
scope=openid profile email&
state=random-csrf-token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
3. ユーザーがログイン + 同意
4. 認可サーバーがAuthorization Codeを返却
302 Redirect: https://myapp.com/callback?code=AUTH_CODE&state=random-csrf-token
5. Authorization CodeをAccess Tokenに交換
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=my-app&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6. トークンレスポンス
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIH...",
"id_token": "eyJhbGci...",
"scope": "openid profile email"
}
5.3 Client Credentials Flow
サーバー間通信(Machine-to-Machine)。ユーザー介入なし。
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=service-a&
client_secret=secret-value&
scope=read:data write:data
レスポンス:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:data write:data"
}
ユースケース:
- マイクロサービス間のAPI呼び出し
- バッチ処理システム
- CI/CDパイプラインでのAPIアクセス
5.4 Device Code Flow
入力が制限されたデバイス用(スマートTV、CLIツール、IoT)
1. デバイスがコードをリクエスト
POST /device/code
client_id=tv-app
レスポンス:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/device",
"interval": 5,
"expires_in": 1800
}
2. ユーザーに表示
"https://auth.example.com/device でコード WDJB-MJHT を入力してください"
3. ユーザーが別のデバイス(スマホ/PC)でコード入力 + ログイン
4. デバイスがポーリング
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&
client_id=tv-app
(ユーザーがまだ認証していない -> "authorization_pending")
(ユーザーが認証完了 -> Access Tokenを返却)
5.5 Implicit Flow(非推奨)
もう推奨されません!PKCEを含むAuthorization Code Flowを使用してください。
非推奨の理由:
1. Access TokenがURL Fragmentに露出(#access_token=...)
2. ブラウザ履歴にトークンが記録
3. Refresh Tokenを発行できない
4. Token Substitution Attackに脆弱
代替案: Authorization Code + PKCE
- SPAでも安全に使用可能
- Refresh Tokenをサポート
- code_verifierでコード横取りを防止
6. OpenID Connect(OIDC)
6.1 OIDCとは
OAuth 2.0上に構築された認証レイヤー。
OAuth 2.0 = 認可(Authorization)のみを担当
OIDC = 認証(Authentication)を追加
OAuth 2.0: 「このアプリがあなたのGoogle Driveにアクセスしてもよいですか?」
OIDC: 「このユーザーが実際にalice@gmail.comであることを確認」
主要な追加事項:
1. ID Token(JWT形式のユーザー情報)
2. UserInfoエンドポイント
3. 標準化されたクレーム(name, email, pictureなど)
4. Discoveryドキュメント(/.well-known/openid-configuration)
6.2 ID Token
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-app-client-id",
"exp": 1711356600,
"iat": 1711353000,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@gmail.com",
"email_verified": true,
"name": "Alice Kim",
"picture": "https://lh3.googleusercontent.com/a/..."
}
6.3 Discoveryドキュメント
GET https://accounts.google.com/.well-known/openid-configuration
レスポンス(主要フィールド):
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
7. SSO(シングルサインオン)
7.1 SSOの概念
一度のログインで複数のサービスにアクセスするメカニズム。
例:
Googleアカウントでログインすると、Gmail、Drive、YouTube、Calendar全てにアクセス可能。
メリット:
- ユーザー体験の向上(ログイン1回)
- パスワード疲労の軽減
- 中央集約されたユーザー管理
- セキュリティポリシーの一貫性
デメリット:
- 単一障害点(IdP障害時に全サービスに影響)
- 実装の複雑さ
- IdPへの依存性
7.2 SAML 2.0 vs OIDC
| 項目 | SAML 2.0 | OIDC |
|---|---|---|
| データ形式 | XML | JSON/JWT |
| トランスポート | HTTP Redirect/POST | HTTP Redirect |
| トークン | Assertion(XML) | ID Token(JWT) |
| 主な用途 | エンタープライズSSO | Web/モバイルアプリ |
| 複雑度 | 高い | 低い |
| モバイル対応 | 限定的 | 優秀 |
| 標準化年 | 2005年 | 2014年 |
SAML 2.0フロー:
1. ユーザーがSP(Service Provider)にアクセス
2. SPがSAML Requestを生成 -> IdPにリダイレクト
3. ユーザーがIdPで認証
4. IdPがSAML Assertion(XML)を生成 -> SPにPOST
5. SPがAssertionを検証 -> セッション作成
OIDCフロー:
1. ユーザーがアプリにアクセス
2. アプリがIdPにAuthorization要求をリダイレクト
3. ユーザーがIdPで認証
4. IdPがAuthorization Codeを返却
5. アプリがCodeをID Token + Access Tokenに交換
8. ソーシャルログイン実装
8.1 Googleログイン(Next.js + NextAuth.js)
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.provider = token.provider;
return session;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
});
export { handler as GET, handler as POST };
8.2 GitHubログイン(Express.js + Passport.js)
const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'https://myapp.com/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
let user = await db.findUserByGitHubId(profile.id);
if (!user) {
user = await db.createUser({
githubId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
}
return done(null, user);
}
));
// ルート
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
8.3 Appleログインの特殊性
Appleログインは他のソーシャルログインとの違いがあります:
1. メールを隠す(Private Email Relay)
- ユーザーが実際のメールの代わりに固有のリレーアドレスを提供可能
- 例: abc123@privaterelay.appleid.com
2. ユーザー情報は初回のみ提供
- 名前、メールは初回ログイン時にのみ返される
- 初回レスポンスで必ず保存すること!
3. client_secretがJWT
- 他のプロバイダーとは異なり、client_secretを直接JWTで生成
- Apple Developer Portalのキーで署名
4. WebではForm POST方式
- コールバックがPOST方式で配信
9. Passkey / WebAuthn / FIDO2
9.1 Passkeyとは
パスワードレス認証の未来。
生体認証(指紋/顔)、デバイスPIN、セキュリティキーでログイン。
Passkey = WebAuthn + FIDO2 + クラウド同期
メリット:
- フィッシング不可能(ドメインにバインド)
- パスワード漏洩なし
- ユーザー体験の向上(タッチ/顔だけでログイン)
- クロスデバイス対応(iCloud Keychain、Google Password Manager)
ブラウザサポート:
- Chrome 108+(2022.12)
- Safari 16+(2022.09)
- Firefox 122+(2024.01)
- Edge 108+(2022.12)
9.2 登録(Registration)フロー
// サーバー: Registration Optionsを生成
app.post('/webauthn/register/options', async (req, res) => {
const user = req.user;
const options = {
challenge: crypto.randomBytes(32),
rp: {
name: 'My App',
id: 'myapp.com'
},
user: {
id: Buffer.from(user.id),
name: user.email,
displayName: user.name
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred',
residentKey: 'required'
},
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// クライアント: Passkeyを作成
const credential = await navigator.credentials.create({
publicKey: registrationOptions
});
// サーバー: 登録完了
app.post('/webauthn/register/verify', async (req, res) => {
const { credential } = req.body;
// challenge検証、origin検証、公開鍵の抽出と保存
const verified = await verifyRegistration(credential, req.session.challenge);
if (verified) {
await db.saveCredential({
credentialId: credential.id,
publicKey: verified.publicKey,
userId: req.user.id,
counter: verified.counter
});
res.json({ success: true });
}
});
9.3 認証(Authentication)フロー
// サーバー: Authentication Optionsを生成
app.post('/webauthn/login/options', async (req, res) => {
const options = {
challenge: crypto.randomBytes(32),
rpId: 'myapp.com',
userVerification: 'preferred',
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// クライアント: Passkeyで認証
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions
});
// サーバー: 認証検証
app.post('/webauthn/login/verify', async (req, res) => {
const { assertion } = req.body;
const credential = await db.findCredential(assertion.id);
const verified = await verifyAuthentication(
assertion,
credential.publicKey,
req.session.challenge,
credential.counter
);
if (verified) {
// カウンターを更新(リプレイ攻撃防止)
await db.updateCounter(assertion.id, verified.newCounter);
// セッション/トークン発行
const token = jwt.sign({ sub: credential.userId }, privateKey);
res.json({ token });
}
});
10. 多要素認証(MFA)
10.1 MFA方式の比較
| 方式 | セキュリティレベル | UX | 実装難易度 |
|---|---|---|---|
| SMS OTP | 低い(SIMスワッピングに脆弱) | 普通 | 低い |
| TOTP(Google Authenticator) | 中程度 | 普通 | 中程度 |
| プッシュ通知(Duo, Okta) | 高い | 良い | 高い |
| ハードウェアキー(YubiKey) | 非常に高い | 普通 | 中程度 |
| Passkey | 非常に高い | 非常に良い | 中程度 |
10.2 TOTP実装
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// MFA設定 - シークレット生成
app.post('/mfa/setup', async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp'
});
// QRコード生成
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// シークレットを一時保存(検証前)
await db.saveTempMfaSecret(req.user.id, secret.base32);
res.json({
qrCode: qrCodeUrl,
manualEntry: secret.base32
});
});
// MFA設定 - コード検証後に有効化
app.post('/mfa/verify-setup', async (req, res) => {
const { code } = req.body;
const secret = await db.getTempMfaSecret(req.user.id);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // 前後30秒を許容
});
if (verified) {
await db.enableMfa(req.user.id, secret);
// バックアップコード生成
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
await db.saveBackupCodes(req.user.id, backupCodes);
res.json({ success: true, backupCodes });
} else {
res.status(400).json({ error: '無効なコード' });
}
});
// ログイン時のMFA検証
app.post('/auth/mfa-verify', async (req, res) => {
const { code, userId } = req.body;
const user = await db.getUser(userId);
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: code,
window: 1
});
if (verified) {
const token = jwt.sign({ sub: user.id, mfa: true }, privateKey);
res.json({ token });
} else {
res.status(401).json({ error: '無効なMFAコード' });
}
});
11. セキュリティベストプラクティス
11.1 CSRF防止
CSRF(Cross-Site Request Forgery):
悪意のあるサイトが認証済みユーザーの権限でリクエストを偽造。
防御方法:
1. SameSiteクッキー属性
Set-Cookie: session=abc; SameSite=Strict
2. CSRFトークン
- サーバーがフォームと共に一意のトークンを発行
- フォーム送信時にトークンを含む
- サーバーがトークンを検証
3. Double Submit Cookie
- CSRFトークンをクッキーとリクエストボディの両方に含む
- 2つの値が一致するか確認
4. Origin/Referer検証
- リクエストのOriginヘッダーが許可されたドメインか確認
11.2 XSS防止とトークン保存
トークン保存場所別セキュリティ比較:
localStorage:
- XSSに脆弱(JavaScriptでアクセス可能)
- CSRFには安全
- 使用しないことを強く推奨
sessionStorage:
- XSSに脆弱
- タブ間で共有されない
- localStorageよりやや良い
HttpOnly Cookie:
- XSSに安全(JavaScriptアクセス不可)
- CSRF保護が必要(SameSite=Strict)
- 最も推奨される方法
メモリ(変数):
- XSS/CSRFの両方に最も安全
- ページリフレッシュで消失
- SPAで使用可能、Refresh TokenはHttpOnlyクッキー
11.3 CORS設定
// Express.js CORS設定
const cors = require('cors');
// Bad: 全ドメインを許可
app.use(cors());
// Good: 特定ドメインのみ許可
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // クッキーの含有を許可
maxAge: 86400 // Preflightキャッシュ24時間
}));
11.4 セキュリティヘッダー
# 必須セキュリティヘッダー
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'; script-src 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
11.5 パスワードポリシー
// bcryptでパスワードハッシュ
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// パスワードハッシュ
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
// パスワード検証
async function verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// パスワード強度の検証
function validatePasswordStrength(password) {
const rules = [
{ test: /.{8,}/, message: '最低8文字以上' },
{ test: /[A-Z]/, message: '大文字1文字以上' },
{ test: /[a-z]/, message: '小文字1文字以上' },
{ test: /[0-9]/, message: '数字1文字以上' },
{ test: /[^A-Za-z0-9]/, message: '特殊文字1文字以上' },
];
const failures = rules.filter(r => !r.test.test(password));
return { valid: failures.length === 0, failures };
}
12. 認証ライブラリ比較
12.1 主要ライブラリ
| ライブラリ | タイプ | 言語 | 主な用途 |
|---|---|---|---|
| NextAuth.js (Auth.js) | ライブラリ | JS/TS | Next.jsソーシャルログイン |
| Passport.js | ミドルウェア | JS | Express多様な戦略 |
| Lucia | ライブラリ | JS/TS | セッションベース、直接制御 |
| Clerk | BaaS | JS/TS | フルスタック認証UI |
| Auth0 | IDaaS | 多様 | エンタープライズSSO |
| Keycloak | セルフホスト | Java | エンタープライズIdP |
| Supabase Auth | BaaS | JS/TS | Supabaseエコシステム |
| Firebase Auth | BaaS | 多様 | Googleエコシステム |
12.2 選択基準
小規模プロジェクト + 迅速な実装:
-> NextAuth.js / Clerk / Supabase Auth
直接制御 + カスタム要件:
-> Lucia / Passport.js
エンタープライズSSO + SAML:
-> Auth0 / Keycloak
マイクロサービス + セルフホスティング:
-> Keycloak
サーバーレス + Googleエコシステム:
-> Firebase Auth
13. セッション vs JWT比較表
| 項目 | セッションベース | JWTベース |
|---|---|---|
| 状態管理 | Stateful(サーバー保存) | Stateless(トークン自己完結) |
| 保存場所 | サーバー(Redis/DB) | クライアント(クッキー/メモリ) |
| スケーラビリティ | セッション共有が必要 | サーバー間共有不要 |
| セキュリティ(盗取時) | サーバーで即座に無効化 | 期限切れまで有効 |
| サイズ | クッキー:小さい(セッションID) | トークン:大きい(Claims含む) |
| マイクロサービス | 中央セッションストア必要 | 各サービスで独立検証 |
| モバイル対応 | クッキー管理が必要 | Authorizationヘッダー使用 |
| ログアウト | セッション削除で即座 | ブラックリストが必要 |
| サーバー負荷 | 毎リクエストDB/Redis参照 | 署名検証のみ(CPU) |
| クロスドメイン | クッキードメイン制限 | ヘッダーで自由に転送 |
| 実装の複雑度 | 低い | 中程度(Refresh Tokenなど) |
| CSRF | 脆弱(クッキー自動送信) | 安全(Authorizationヘッダー) |
| XSS | 安全(HttpOnlyクッキー) | 脆弱(localStorage保存時) |
| オフライン利用 | 不可 | 可能(トークン内情報活用) |
| デバッグ | サーバーログ確認 | jwt.ioでデコード可能 |
14. クイズ
Q1. Authentication vs Authorization
401 Unauthorizedと403 Forbiddenの違いを具体的なシナリオで説明してください。
回答:
-
401 Unauthorized(認証失敗): ユーザーが自分の身元を証明していない状態。例:Authorizationヘッダーなしでのリクエスト、期限切れJWTトークンの使用、間違ったパスワードの入力。「あなたが誰かわかりません。」
-
403 Forbidden(認可失敗): ユーザーは認証済みだが、そのリソースに対する権限がない状態。例:一般ユーザーがDELETE /admin/users APIを呼び出す、他のユーザーの非公開データへのアクセスを試みる。「あなたが誰かは知っていますが、この操作を行う権限がありません。」
Q2. JWTセキュリティ
JWTをlocalStorageに保存してはいけない理由と推奨される保存方法を説明してください。
回答:
localStorageに保存するとXSS(クロスサイトスクリプティング)攻撃に脆弱です。悪意のあるスクリプトが注入されると、localStorage.getItem('token')でトークンを盗取できます。
推奨される保存方法:
- Access Token: メモリ(JavaScript変数)に保存。ページリフレッシュで消失するが最も安全。
- Refresh Token: HttpOnly + Secure + SameSite=Strictクッキーに保存。JavaScriptからアクセス不可でXSSに安全。
- Access Tokenが期限切れになったらRefresh Tokenクッキーで更新。
Q3. OAuth 2.0 PKCE
SPAでAuthorization Code FlowにPKCEが必須である理由を説明してください。
回答: SPAはソースコードがブラウザに完全に露出されるため、client_secretを安全に保管できません。PKCEなしでは、Authorization Codeが横取りされた場合、攻撃者がこれをAccess Tokenに交換できます。
PKCEはこの問題を解決します:
- クライアントが
code_verifier(ランダム文字列)を生成 code_challenge = SHA256(code_verifier)を認可リクエストに含む- トークン交換時に元の
code_verifierを送信 - サーバーが
SHA256(code_verifier) == code_challengeを検証
攻撃者がAuthorization Codeを横取りしても、code_verifierを知らなければトークン交換は不可能です。
Q4. Passkey
Passkeyがパスワードより安全な理由を3つ説明してください。
回答:
-
フィッシング不可能: Passkeyは登録されたドメイン(RP ID)にバインドされます。偽サイトではPasskeyが機能しないため、フィッシング攻撃が根本的にブロックされます。
-
サーバー漏洩に安全: サーバーには公開鍵のみが保存されます。DBが漏洩しても公開鍵では認証を偽造できません。パスワードはハッシュ化されて保存されますが、脆弱なパスワードはクラックされる可能性があります。
-
再利用不可: 各サービスごとに固有の鍵ペアが生成されます。1つのサービスで漏洩しても他のサービスに影響がありません。一方、パスワードはユーザーが複数のサイトで再利用するケースが多いです。
Q5. セッション vs JWT
マイクロサービス環境でセッションベース認証よりJWTが有利な理由を説明してください。
回答: マイクロサービス環境でJWTが有利な理由:
-
独立した検証: 各サービスが公開鍵さえあれば独立してトークンを検証できます。セッションは中央セッションストア(Redis)を全サービスが共有する必要があります。
-
ネットワーク負荷の削減: JWTは署名検証のみで認証が完了し、毎リクエストのDB/Redis参照が不要です。
-
サービス間伝播の容易さ: JWTをAuthorizationヘッダーに含めてサービス間で転送するだけです。ユーザー情報がトークン内に含まれているため、追加の問い合わせが不要です。
-
スケーリングの容易さ: 新しいサービスインスタンスの追加時にセッションストアの接続設定が不要で、公開鍵の配布だけで済みます。
参考資料
- OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749
- JWT RFC 7519 - https://tools.ietf.org/html/rfc7519
- PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636
- OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html
- WebAuthn Specification - https://www.w3.org/TR/webauthn-2/
- FIDO2 Specifications - https://fidoalliance.org/fido2/
- OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Auth0 Documentation - https://auth0.com/docs
- NextAuth.js (Auth.js) Docs - https://authjs.dev/
- Passkeys.dev - https://passkeys.dev/
- SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
- Keycloak Documentation - https://www.keycloak.org/documentation