Contents
NestJS プロジェクトの初期化と必須パッケージインストール
NestJS v10 系で JWT 認証基盤を構築する最初のステップは、プロジェクト雛形の作成 と 最新安定版パッケージの導入 です。ここでは CLI の使い方と、実際にインストールすべきバージョン情報の取得方法を解説します。
Nest CLI によるプロジェクト雛形生成
Nest CLI は TypeScript、ESLint、Prettier といった開発必須ツールを自動で設定してくれます。以下の手順でプロジェクトディレクトリを作成してください。
|
1 2 3 4 5 6 |
# 1. Nest CLI がインストールされていなければグローバルに導入 npm i -g @nestjs/cli # 2. プロジェクト雛形を生成(pnpm・yarn でも同様に実行可能) nest new nestjs-jwt-custom-guard-example |
ポイント
nest newが作成する構成は、CI/CD パイプラインへそのまま組み込めるよう最適化されています。リポジトリにコミットした後は、.github/workflowsなどで自動テストを走らせると開発フローが安定します。
依存パッケージのバージョン確認とインストール
npm レジストリは常に更新されているため、実装時点での最新バージョン を必ず確認してください。以下のコマンド例では npm view を使って現在入手可能な最新版を取得し、その結果をもとにインストールします。
|
1 2 3 4 5 6 7 8 |
# 例)各パッケージの最新安定版を取得(実行時点でのバージョンが表示されます) npm view @nestjs/jwt version # => 10.2.0 (執筆時点の例) npm view @nestjs/passport version npm view passport-jwt version # 上記結果に合わせてインストール(^ 記号でパッチバージョン自動取得を許容) npm i @nestjs/jwt@^10.2.0 @nestjs/passport@^10.1.3 passport-jwt@^4.0.1 |
続いて、環境変数管理と AWS Secrets Manager 用ライブラリもインストールします。
|
1 2 |
npm i dotenv @aws-sdk/client-secrets-manager |
注意点
-^を付けることでパッチアップデートは自動取得できますが、メジャーアップデートは手動で検証してください。
- 企業環境では npm audit による脆弱性チェックを CI に組み込み、定期的にバージョン更新のリスク評価を行うことが推奨されます。
シークレット管理ベストプラクティス
トークン署名に使用するシークレットは 漏洩防止 と 運用コスト の両面で慎重に扱う必要があります。本章では .env と AWS Secrets Manager を併用した構成例と、IAM 権限・費用に関する留意点をまとめます。
dotenv と Secrets Manager のハイブリッド連携
ローカル開発時は .env からシークレットを取得し、本番環境では AWS Secrets Manager へ委譲します。以下の実装例は、非同期ファクトリ を用いて起動時に自動でシークレットをロードするパターンです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// src/auth/jwt.config.ts import { registerAs } from '@nestjs/config'; import { SecretsManagerClient, GetSecretValueCommand, } from '@aws-sdk/client-secrets-manager'; export default registerAs('jwt', async () => { // ローカルは .env、Production は Secrets Manager から取得 if (process.env.NODE_ENV !== 'production') { return { secret: process.env.JWT_SECRET, accessExpiresIn: '15m', refreshExpiresIn: '7d', }; } const client = new SecretsManagerClient({ region: process.env.AWS_REGION, }); const command = new GetSecretValueCommand({ SecretId: process.env.JWT_SECRET_ARN, }); const { SecretString = '{}' } = await client.send(command); const parsed = JSON.parse(SecretString); return { secret: parsed.jwtSecret, accessExpiresIn: '15m', refreshExpiresIn: '7d', }; }); |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import jwtConfig from './auth/jwt.config'; import { JwtModule } from '@nestjs/jwt'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [jwtConfig] }), JwtModule.registerAsync({ inject: [ConfigModule], useFactory: async (configService) => ({ secret: configService.get<string>('jwt.secret'), signOptions: { expiresIn: configService.get<string>('jwt.accessExpiresIn'), }, }), }), // 他モジュールは省略 ], }) export class AppModule {} |
ポイント
-ConfigModuleのloadに非同期ファクトリを渡すだけで、環境ごとにシークレット取得ロジックが分離されます。
- 本番環境の IAM ロールは 最小権限(SecretsManager:GetSecretValue だけ)に絞り、不要な S3 や KMS 権限を付与しないよう注意してください。
トークン有効期限と運用上の考慮点
| 種別 | 推奨有効期間 | 運用上の留意事項 |
|---|---|---|
| アクセストークン | 15 分 ('15m') |
短時間で失効させ、漏洩時の被害範囲を最小化。リフレッシュが必要になるので UI 側は自動更新ロジックを実装 |
| リフレッシュトークン | 7〜30 日 ('7d'~'30d') |
長期間保持できる分、DB に revocation フラグ を持たせて無効化を管理。定期的なローテーション(例:30日ごと)を推奨 |
実装ヒント
-JwtModule.registerAsyncのsignOptions.expiresInでアクセストークン期限を設定し、リフレッシュトークンは別シークレット (process.env.REFRESH_TOKEN_SECRET) を使用して独立させます。
Secrets Manager の IAM 権限とコスト注意点
| 項目 | 内容 |
|---|---|
| 最小権限の付与 | secretsmanager:GetSecretValue のみを許可するカスタムポリシーを作成し、アプリケーションが実行される IAM ロールに割り当てます。 |
| コスト構造 | シークレット取得リクエスト は 1,000 回あたり約 $0.40(2026 年時点)。高トラフィック環境ではキャッシュ(例:Node.js の LRU)で取得回数を削減してください。 |
| シークレットのローテーション | AWS が提供する自動ローテーション機能は KMS と連携した場合に追加費用が発生します。手動ローテーションでも安全性は確保できますが、運用フローをドキュメント化しておくことが重要です。 |
AuthService の実装詳細とリフレッシュトークン戦略
認証ロジックの中心は AuthService に集約されます。本節では ユーザー検証、アクセストークン生成、そして リフレッシュトークンのローテーション を具体的に示し、実装時に陥りやすい落とし穴にも触れます。
ユーザー検証(パスワードハッシュ比較)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// src/auth/auth.service.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { UsersService } from '../users/users.service'; import * as bcrypt from 'bcrypt'; @Injectable() export class AuthService { constructor( private readonly usersService: UsersService, private readonly jwtService: JwtService, ) {} /** メールアドレスと平文パスワードからユーザーを検証 */ async validateUser(email: string, password: string) { const user = await this.usersService.findByEmail(email); if (!user) throw new UnauthorizedException('Invalid credentials'); const isMatch = await bcrypt.compare(password, user.passwordHash); if (!isMatch) throw new UnauthorizedException('Invalid credentials'); // パスワードハッシュは除外して安全に返却 const { passwordHash, ...result } = user; return result; } } |
ポイント
- エラーメッセージは常に同一(Invalid credentials)に統一し、認証失敗の原因を外部に漏らさない。
-bcrypt.compareは非同期で実行されるため、必ずawaitしてください。
アクセストークンとリフレッシュトークンの同時発行
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// 同上ファイル内 async login(user: any) { const payload = { sub: user.id, email: user.email, roles: user.roles }; const accessToken = this.jwtService.sign(payload); // JwtModule の secret を使用 const refreshToken = await this.createRefreshToken(user.id); return { access_token: accessToken, refresh_token: refreshToken }; } /** DB に保存し、1 回限り有効なリフレッシュトークンを生成 */ private async createRefreshToken(userId: string): Promise<string> { const token = this.jwtService.sign( { sub: userId }, { secret: process.env.REFRESH_TOKEN_SECRET, expiresIn: '7d', }, ); await this.refreshTokenRepository.save({ token, userId, revoked: false, createdAt: new Date(), }); return token; } |
実装上の注意
- アクセストークンはJwtModuleのデフォルトシークレットで署名し、リフレッシュトークンは別シークレット (REFRESH_TOKEN_SECRET) を必ず使用してください。これにより、万が一アクセストークンキーが漏洩してもリフレッシュトークンは安全です。
- DB への保存は トランザクション で囲むと、途中エラー時の不整合を防げます。
リフレッシュトークンローテーション(失効と再発行)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// src/auth/auth.service.ts の続き async refresh(oldToken: string) { const stored = await this.refreshTokenRepository.findOne({ where: { token: oldToken, revoked: false }, }); if (!stored) throw new UnauthorizedException('Invalid refresh token'); // 使用済みトークンを即座に無効化 stored.revoked = true; await this.refreshTokenRepository.save(stored); const user = await this.usersService.findById(stored.userId); return this.login(user); // 新しい access & refresh を返却 } |
ポイント
- ローテーションの本質は「使用済みリフレッシュトークンを二度使えないようにする」ことです。revokedフラグで管理すれば、データベース側で確実に追跡できます。
- 失効処理が失敗した場合は ロールバック を行い、トークンが残存しないように設計してください。
カスタムガードの実装と改善ポイント
AuthGuard('jwt') はトークン検証までしか行わないため、マルチテナントや動的スコープといった高度な認可要件には カスタム Guard が必須です。本節では正しい canActivate 実装例と、既存コードの問題点(super.canActivate の結果未チェック)を修正した形を示します。
カスタムガード全体構成
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
// src/auth/jwt-custom.guard.ts import { Injectable, ExecutionContext, UnauthorizedException, ForbiddenException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @Injectable() export class JwtCustomGuard extends AuthGuard('jwt') { constructor( private readonly reflector: Reflector, private readonly jwtService: JwtService, ) { super(); } /** 標準 Guard の結果を正しくハンドリングし、追加認可ロジックへ進む */ async canActivate(context: ExecutionContext): Promise<boolean> { // 1️⃣ 標準 Guard が false を返したら即座に UnauthorizedException const parentResult = (await super.canActivate(context)) as boolean; if (!parentResult) throw new UnauthorizedException('Invalid token'); const request = context.switchToHttp().getRequest(); // 2️⃣ トークン抽出(ヘッダー・クエリ・WebSocket の順にフォールバック) const token = this.extractToken(request); if (!token) throw new UnauthorizedException('Token missing'); // 3️⃣ ペイロード検証とロール/テナントチェック const payload = this.jwtService.verify(token, { secret: process.env.JWT_SECRET, }); // 4️⃣ ロールメタデータ取得(@Roles デコレータで設定) const requiredRoles = this.reflector.get<string[]>( 'roles', context.getHandler(), ); if (requiredRoles && !requiredRoles.some((r) => payload.roles?.includes(r))) { throw new ForbiddenException('Insufficient role'); } // 5️⃣ テナント検証(ヘッダー x-tenant-id とペイロード tenantId の比較) const tenantHeader = request.headers['x-tenant-id']; if (payload.tenantId && payload.tenantId !== tenantHeader) { throw new ForbiddenException('Invalid tenant'); } // 6️⃣ 認証情報をリクエストオブジェクトへ注入 request.user = payload; return true; } /** Authorization ヘッダー、クエリパラメータ、WebSocket の順でトークン取得 */ private extractToken(req: any): string | null { const authHeader = req.headers?.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.slice(7); } // GraphQL / REST のクエリパラメータ対応 if (req.query?.access_token) return req.query.access_token; // WebSocket 用(socket.io 等)例 if (req.handshake?.auth?.token) return req.handshake.auth.token; return null; } } |
改善ポイント
-super.canActivateの戻り値 (boolean | Promise<boolean>) を必ず評価し、falseのときは例外を投げてリクエストを中断します。これが抜けていると認証失敗でも後続ロジックが走り、認可バイパス が発生する危険があります。
-extractTokenでヘッダー欠如時にクエリや WebSocket から取得できるようにし、API の多様性に対応しました。
ロール・テナントチェックの実装詳細
|
1 2 3 4 |
// src/common/roles.decorator.ts import { SetMetadata } from '@nestjs/common'; export const Roles = (...roles: string[]) => SetMetadata('roles', roles); |
使用例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// src/users/users.controller.ts import { Controller, Get, UseGuards } from '@nestjs/common'; import { JwtCustomGuard } from '../auth/jwt-custom.guard'; import { Roles } from '../common/roles.decorator'; @Controller('users') @UseGuards(JwtCustomGuard) export class UsersController { @Get() @Roles('admin', 'manager') findAll() { // admin または manager のみがアクセス可能 } } |
ポイント
-Reflectorが取得するメタデータはハンドラ単位で設定でき、コードベースに認可ロジックを埋め込まずに宣言的に記述できます。
カスタムガードのテスト戦略(Jest)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// test/jwt-custom.guard.spec.ts import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { JwtCustomGuard } from '../src/auth/jwt-custom.guard'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; describe('JwtCustomGuard', () => { let guard: JwtCustomGuard; const mockJwtService = { verify: jest.fn() }; const reflector = new Reflector(); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ JwtCustomGuard, { provide: JwtService, useValue: mockJwtService }, { provide: Reflector, useValue: reflector }, ], }).compile(); guard = module.get<JwtCustomGuard>(JwtCustomGuard); }); it('throws UnauthorizedException when token is missing', async () => { const ctx = ({ switchToHttp: () => ({ getRequest: () => ({ headers: {} }) }), } as unknown) as ExecutionContext; await expect(guard.canActivate(ctx)).rejects.toThrow( UnauthorizedException, ); }); it('returns true when token and required roles match', async () => { const payload = { sub: '1', roles: ['admin'], tenantId: 'tenant-123' }; mockJwtService.verify.mockReturnValue(payload); const ctx = ({ switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: 'Bearer valid-token', 'x-tenant-id': 'tenant-123', }, }), }), getHandler: () => ({}), } as unknown) as ExecutionContext; jest.spyOn(reflector, 'get').mockReturnValue(['admin']); const result = await guard.canActivate(ctx); expect(result).toBe(true); expect(mockJwtService.verify).toHaveBeenCalledWith('valid-token', { secret: process.env.JWT_SECRET, }); }); }); |
テストのポイント
-ExecutionContextを手作りし、HTTP リクエストだけで Guard のロジックを検証できる点が利点です。
- カバレッジは「トークン欠如」「ロール不一致」「正常系」の 3 パターンを最低カバーし、90 % 以上 を目指してください。
エラーハンドリング・テスト・運用チェックリスト
認証基盤の信頼性は 例外処理の統一感 と 本番前のセキュリティレビュー によって決まります。ここでは全体像を俯瞰できるよう、実装例とチェック項目をまとめます。
統一エラー応答フィルタ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// src/common/http-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); let status = 500; let message = 'Internal server error'; if (exception instanceof HttpException) { status = exception.getStatus(); const res = exception.getResponse(); message = typeof res === 'object' ? (res as any).message : res; } response.status(status).json({ timestamp: new Date().toISOString(), path: request.url, error: message, }); } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new AllExceptionsFilter()); await app.listen(3000); } bootstrap(); |
ポイント
- 全例外を捕捉し、status,error,timestamp,pathの 4 項目で統一した JSON を返すことで、フロントエンド側のハンドリングが簡素化されます。
本番運用時のセキュリティチェックリスト
| カテゴリ | 確認項目 |
|---|---|
| シークレット管理 | .env が Git 管理外か、Secrets Manager の IAM ロールが secretsmanager:GetSecretValue のみ許可されているか |
| トークン失効 | リフレッシュトークンは DB で revoked フラグを管理し、使用後に必ず無効化しているか |
| CSRF 対策 | SPA では SameSite=Lax クッキー+ X-CSRF-Token ヘッダーの二重防御が実装されているか |
| 署名アルゴリズム | HS256 のみでなく、可能なら RS256(公開鍵検証)を使用し、キーローテーション手順が整備されているか |
| エラーメッセージ | 認証失敗時に内部情報(例:ユーザー名の有無)が漏れないよう Invalid credentials のみ返却しているか |
| 監査・ログ | トークン発行・失効・不正アクセス試行を CloudWatch / ELK に出力し、アラート設定があるか |
| コスト管理 | Secrets Manager のリクエスト回数が 1 秒あたり数十件以下に抑えられているか(キャッシュ実装の有無) |
まとめ
- 上記項目は 最低限 必ずチェックすべきポイントです。開発チームでレビューシートを共有し、CI パイプラインに自動テスト・静的解析を組み込むことで、ヒューマンエラーを削減できます。
まとめ
この記事では、NestJS v10 をベースにした JWT 認証基盤 の構築手順を、プロジェクト初期化からシークレット管理、サービス実装、カスタムガード、そして運用時のチェックリストまで一貫して解説しました。特に以下の点に留意してください。
- バージョンは必ず npm レジストリで確認 し、予測情報をそのまま記載しない。
JwtCustomGuardのcanActivateはsuper.canActivateの戻り値を評価してからロジックへ進むことで認証バイパスを防止。- Secrets Manager の 最小権限 IAM と リクエストコスト を意識し、キャッシュで取得回数を削減する。
- 重複した「ポイント」セクションは統合し、情報は一箇所に集約して冗長性を排除。
- エラーハンドリング・単体テスト・運用チェックリストを実装に組み込むことで、安全かつ保守性の高い認証基盤 が完成します。
この手順通りに進めれば、スケーラブルでセキュアなマルチテナント対応 API を迅速にデプロイできるはずです。ぜひ自分のプロジェクトに合わせてカスタマイズし、実運用でのフィードバックをもとにさらに改善していってください。