JWT 認証: 2026 年のセキュリティのベスト プラクティス
JSON Web トークンはどこにでも存在しますが、ほとんどの実装には少なくとも 1 つの重大なセキュリティ上の欠陥があります。攻撃対象領域は見た目よりも大きく、実稼働システムで最もよく見られる脆弱性としては、アルゴリズムの混乱攻撃、XSS によるトークンの盗難、有効期限検証の欠如、不適切な秘密管理などが挙げられます。 JWT を正しく取得することは、ライブラリを呼び出して先に進むことではありません。あらゆる層で慎重な決定が必要です。
このガイドでは、署名アルゴリズムの選択とトークン構造からストレージ、ローテーション、失効、実際の NestJS 実装パターンに至るまで、JWT セキュリティ ライフサイクル全体をカバーしているため、実際に攻撃に耐えられる認証を構築できます。
重要なポイント
- 分散システムには常に RS256 (非対称) を使用します。 HS256 は、API サーバーが発行者と検証者の両方である場合のみ
- トークンを HttpOnly、Secure、SameSite=Lax Cookie に保存します。localStorage や sessionStorage には決して保存しないでください。
exp、iss、aud、およびalgクレームを常に検証します。署名されていないnoneアルゴリズムを決して信頼しないでください。- リフレッシュ トークンのローテーションを実装します。リフレッシュごとに新しいペアが発行され、古いリフレッシュ トークンが無効になります。
- アクセス トークンの TTL を短くします (15 分)。データベースに保存されている不透明なリフレッシュ トークンを使用する
- 機密データ (パスワード、SSN、支払い情報) を JWT ペイロードに保存しないでください。ペイロードは Base64 であり、暗号化されていません。
- Redis 拒否リストまたはデータベース内のバージョン カウンターを介してトークン取り消しを実装します。
- すべてのトークン発行をログに記録し、セキュリティ監査証跡のイベントを更新します
JWT の構造とクレーム
JWT には、ヘッダー、ペイロード、署名の 3 つの部分があり、ドットで区切られ、base64url エンコードされています。
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc0MjM0NTYwMCwiZXhwIjoxNzQyMzQ2NTAwLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIn0.
[signature]
デコードされたペイロード:
{
"sub": "user_123",
"email": "[email protected]",
"role": "admin",
"iat": 1742345600,
"exp": 1742346500,
"iss": "https://api.example.com",
"aud": "https://app.example.com"
}
本番環境に必要なクレーム:
sub— 一意のユーザー識別子 (電子メールのみを使用しないでください。電子メールは変更されます)exp— 有効期限タイムスタンプ (常に必須)iat— 発行時のタイムスタンプ (クロック スキューの検出)iss— 発行者の URL (予想される発行者に対して検証)aud— 対象者 (サービス間でのトークンの再利用を防ぐために検証)jti— JWT ID (トークンごとに一意、正確な取り消しが可能)
RS256 と HS256: どちらのアルゴリズムを使用するか
これは、JWT 構成におけるセキュリティ上の唯一の最も影響力のある決定です。
HS256 (HMAC-SHA256) — 対称
// Both signing and verifying require the same secret
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
const verified = jwt.verify(token, process.env.JWT_SECRET);
次の場合にのみ使用してください: トークンに署名するサービスは、トークンを検証するサービスと同じです。 HS256 はモノリシック API には適していますが、マイクロサービスでは危険です。トークンを検証できるサービスであればトークンを作成することもできます。
RS256 (RSA-SHA256) — 非対称
// Sign with private key (only the auth server holds this)
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', keyid: 'key-2026-01' });
// Verify with public key (any service can do this safely)
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // NEVER omit this — prevents algorithm confusion
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
実稼働 RSA キー ペアを生成します。
# Generate 4096-bit RSA private key
openssl genrsa -out jwt-private.pem 4096
# Extract public key
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
キーのローテーション: ヘッダーで keyid (kid) を使用します。現在の公開キーを /.well-known/jwks.json で公開します。サービスは JWKS をキャッシュし、不明な kid をフェッチします。
HttpOnly Cookie ストレージ
これは交渉の余地がありません。トークンを localStorage または sessionStorage に保存すると、XSS 攻撃によって挿入されたスクリプトを含め、ページ上で実行されているあらゆる JavaScript からトークンにアクセスできるようになります。
// NestJS: set HttpOnly cookie after authentication
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { accessToken, refreshToken } = await this.authService.login(dto);
const cookieBase = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
domain: process.env.COOKIE_DOMAIN, // '.example.com' for subdomain sharing
};
res.cookie('access_token', accessToken, {
...cookieBase,
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refresh_token', refreshToken, {
...cookieBase,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/auth/refresh', // Scope refresh token to only the refresh endpoint
});
return { message: 'Login successful' };
}
// Extract token from cookie in JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => req?.cookies?.access_token, // Cookie first
ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer fallback for API clients
]),
secretOrKey: configService.get('JWT_PUBLIC_KEY'),
algorithms: ['RS256'],
issuer: configService.get('JWT_ISSUER'),
audience: configService.get('JWT_AUDIENCE'),
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
// Always check token version against database
const user = await this.usersService.findById(payload.sub);
if (!user || user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException('Token invalidated');
}
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
リフレッシュトークンのローテーション
有効期間の長いアクセス トークンを発行しないでください。代わりに、アクセス トークンの有効期間を短くし、使用するたびに更新トークンをローテーションします。
// auth.service.ts
@Injectable()
export class AuthService {
async refreshTokens(refreshToken: string, ipAddress: string) {
// 1. Look up the refresh token in the database
const storedToken = await this.db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.token, this.hashToken(refreshToken)),
eq(refreshTokens.revoked, false),
gt(refreshTokens.expiresAt, new Date())
),
with: { user: true },
});
if (!storedToken) {
// Possible reuse attack — revoke all tokens for this user
if (storedToken?.userId) {
await this.revokeAllUserTokens(storedToken.userId);
await this.alertService.send({
message: `Refresh token reuse detected for user ${storedToken.userId}`,
severity: 'high',
});
}
throw new UnauthorizedException('Invalid refresh token');
}
// 2. Revoke the used refresh token (rotation)
await this.db
.update(refreshTokens)
.set({ revoked: true, revokedAt: new Date(), revokedByIp: ipAddress })
.where(eq(refreshTokens.id, storedToken.id));
// 3. Issue new token pair
const newAccessToken = this.issueAccessToken(storedToken.user);
const newRefreshToken = await this.issueRefreshToken(
storedToken.user.id,
ipAddress,
storedToken.family // Track token families for reuse detection
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}
リフレッシュ トークンのデータベース スキーマ:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of the actual token
family UUID NOT NULL, -- Token family for reuse detection
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT false,
revoked_at TIMESTAMPTZ,
revoked_by_ip INET,
created_at TIMESTAMPTZ DEFAULT now(),
created_by_ip INET
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
トークン失効戦略
JWT は設計上ステートレスです。一度発行されると、インフラストラクチャを追加しない限り「取り戻す」ことはできません。以下に、トレードオフによってランク付けされた 3 つのアプローチを示します。
1. 短い TTL (15 分)
最も単純な失効戦略: アクセス トークンはすぐに期限切れになるため、失効する必要はほとんどありません。データベース内の即時リフレッシュ トークン取り消しと組み合わせます。
2. トークンバージョンカウンター
tokenVersion を users テーブルに保存します。既存のトークンをすべて無効にするには、値をインクリメントします。
// Increment version to logout all sessions
await this.db
.update(users)
.set({ tokenVersion: sql`token_version + 1` })
.where(eq(users.id, userId));
// In JWT payload
const payload = {
sub: user.id,
tokenVersion: user.tokenVersion, // embedded at sign time
};
// In JwtStrategy.validate()
if (user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException();
}
リクエストごとに 1 つのデータベース ルックアップが必要です - ほとんどのアプリケーションで許容されます。
3. Redis 拒否リスト
データベース検索を行わずに即時取り消す場合:
// Revoke specific token by JTI
async revokeToken(jti: string, expiresIn: number): Promise<void> {
const key = `token:revoked:${jti}`;
await this.redis.set(key, '1', 'EX', expiresIn);
}
// Check in JWT strategy before accepting
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
const revoked = await this.redis.get(`token:revoked:${payload.jti}`);
if (revoked) {
throw new UnauthorizedException('Token revoked');
}
return this.buildUser(payload);
}
トレードオフ: リクエストごとに 1 つの Redis ルックアップが行われますが、Redis は O(1) でミリ秒未満です。高度なセキュリティのエンドポイントに受け入れられます。
一般的な脆弱性と軽減策
アルゴリズム混乱攻撃
alg: "none" 攻撃: 攻撃者は署名を剥奪し、alg を none に設定し、改ざんされたペイロードを送信します。未署名のトークンを受け入れるライブラリは、あらゆるペイロードを受け入れます。
// WRONG — never do this
jwt.verify(token, secret); // Accepts alg:none if library allows it
// CORRECT — always specify algorithms explicitly
jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Whitelist only what you use
});
JWT ヘッダー インジェクション (jwk/jku)
攻撃者は、独自の鍵サーバーを指す jku (JWKS URL) または jwk (インライン キー) ヘッダーを含む JWT を作成し、独自の秘密鍵で署名します。脆弱な検証ツールは攻撃者のキーをフェッチし、トークンを受け入れます。
// WRONG — never fetch keys from the token header
const jwksUri = decodedHeader.jku; // Attacker-controlled!
// CORRECT — always use a pinned, config-driven JWKS URI
const jwksClient = createRemoteJWKSet(new URL(configService.get('JWKS_URI')));
HS256 の弱い秘密
HS256 の 32 文字の ASCII シークレットのエントロピーは約 190 ビットですが、不十分です。暗号的に安全なランダム ソースから少なくとも 256 ビットを使用します。
# Generate a strong HS256 secret
node -e "console.log(require('crypto').randomBytes(64).toString('base64url'))"
NestJS JWT モジュールの構成
// auth.module.ts
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
privateKey: config.get('JWT_PRIVATE_KEY'),
publicKey: config.get('JWT_PUBLIC_KEY'),
signOptions: {
algorithm: 'RS256',
expiresIn: '15m',
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
verifyOptions: {
algorithms: ['RS256'],
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
}),
}),
],
})
export class AuthModule {}
公開鍵配布用の JWKS エンドポイント
// jwks.controller.ts
@Controller('.well-known')
export class JwksController {
@Get('jwks.json')
@Public()
async getJwks() {
const publicKeyPem = this.configService.get('JWT_PUBLIC_KEY');
// Convert PEM to JWK format using the 'jose' library
const publicKey = await importSPKI(publicKeyPem, 'RS256');
const jwk = await exportJWK(publicKey);
return {
keys: [
{
...jwk,
use: 'sig',
alg: 'RS256',
kid: this.configService.get('JWT_KEY_ID'), // e.g., 'key-2026-01'
},
],
};
}
}
このエンドポイントを積極的にキャッシュします。公開キーはほとんど変更されません。 Cache-Control: public, max-age=3600 を設定します。
セキュリティチェックリスト
JWT 認証を実稼働環境にデプロイする前に、以下を確認してください。
- 署名呼び出しと検証呼び出しの両方でアルゴリズムが明示的に
RS256またはHS256に設定される -
exp、iss、audはリクエストごとに検証されます - アクセストークン TTL ≤ 15 分
- HttpOnly、Secure、SameSite=Lax Cookie に保存されたトークン
- リフレッシュ トークンのローテーションが実装されました - 古いトークンは使用するたびに取り消されます
- データベースに SHA256 ハッシュとして保存されたリフレッシュ トークン (プレーンテキストではない)
-
jtiクレームが対象の取り消し機能のために追加されました - Redis 拒否リストで高セキュリティのエンドポイントがチェックされました
- 分散サービス用に公開された JWKS エンドポイント
- 秘密キーはシークレットマネージャー (AWS Secrets Manager、Vault) に保存されます。
- キーのローテーション手順が文書化され、テストされました
- すべてのトークン発行および無効化イベントがログに記録されます
よくある質問
署名を検証せずに JWT をデコードしても安全ですか?
検証を行わずにデコードしても、ペイロードを読み取る場合は安全ですが、承認のために内容を信頼してはなりません。申し立てに基づいて行動する前に、必ず署名を確認してください。ほとんどのライブラリの jwt.decode() 関数は検証をスキップします。診断に使用するか、検証に適切なキーを選択する前にヘッダーから kid を読み取る場合にのみ使用します。
ブラウザ アプリには Cookie または Authorization ヘッダーを使用する必要がありますか?
ブラウザ アプリケーションの HttpOnly Cookie、ネイティブ モバイル アプリケーションの認証ヘッダー、およびサーバー間 API 呼び出し。 Cookie は XSS 漏洩の影響を受けません (JavaScript は HttpOnly Cookie を読み取ることができません)。モバイル アプリは Cookie を効果的に使用できず、デバイスの安全なキーストアに保存されているベアラー トークンを使用します。
フロントエンドでトークンの有効期限を処理するにはどうすればよいですか?
401 応答をインターセプトし、元のリクエストを再試行する前にサイレント リフレッシュを試みます。 React では、Axios またはフェッチ インターセプターを使用します。更新も失敗した場合 (期限切れまたは取り消された場合)、ログインにリダイレクトします。並行リフレッシュ ストームを防ぐために、1 つの実行中のリフレッシュを約束してください。
アクセス トークンとリフレッシュ トークンの違いは何ですか?
アクセス トークンは有効期間が短く (15 分)、ステートレスで、API リクエストごとに公開キーまたは共有秘密を使用して検証されます。リフレッシュ トークンは有効期間が長く (7 ~ 30 日間)、不透明 (JWT ではなくランダムな文字列) で、サーバー側のデータベースに保存されます。リフレッシュ トークン エンドポイントは、リフレッシュ トークンが使用される唯一の場所です。狭い Cookie パスでスコープを設定します。
ユーザー ロールを JWT ペイロードに保存できますか?
はい、ただし、トークンにエンコードされたロールは、トークンの有効期限が切れるまでキャッシュされることに注意してください。ユーザーの管理者ロールを取り消すと、現在のアクセス トークンの有効期限が切れるまで (最大 15 分)、その管理者ロールが保持されます。高セキュリティのロールを変更する場合は、ユーザーを Redis 拒否リストに追加するか、トークンのバージョンをインクリメントして即時再認証を強制します。
JWT で「remember me」を実装するにはどうすればよいですか?
ユーザーが「私を記憶する」にチェックを入れた場合、標準の 30 日間ではなく、より長い有効期間の更新トークン (90 日間) を発行します。 persistent フラグをリフレッシュ トークン データベース行に保存すると、ユーザーのセキュリティ設定で永続セッションを個別に表示および取り消すことができます。アクセス トークンの TTL を延長しないでください。これでは目的が果たせません。
次のステップ
適切に行われた JWT 認証は、あらゆる安全な Web アプリケーションの基盤です。 HttpOnly Cookie ストレージを介した RS256 署名、リフレッシュ トークンのローテーション、失効戦略に至るまで、このガイドのパターンは最も一般的な認証攻撃からユーザーを保護します。
ECOSIRE は、すべてのバックエンド プロジェクトにわたって、Authentik との OIDC 統合、HttpOnly Cookie フロー、Redis を利用したトークン管理など、実証済みの認証アーキテクチャを実装しています。 セキュリティに重点を置いた開発サービスを探索 して、認証層を強化する方法を確認してください。
執筆者
ECOSIRE Research and Development Team
ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
Authentik OIDC/SSO: Complete Integration Guide
Complete Authentik OIDC and SSO integration guide: OAuth2 provider setup, Next.js callback handling, NestJS JWT validation, user provisioning, and production configuration.
Cybersecurity Trends 2026-2027: Zero Trust, AI Threats, and Defense
The definitive guide to cybersecurity trends for 2026-2027—AI-powered attacks, zero trust implementation, supply chain security, and building resilient security programs.