NestJSアプリケーションでJWT認証を実装する際、すべての保護されたエンドポイントでトークン検証を行う必要があります。AuthGuardを使用することで、認証ロジックをControllerから分離し、宣言的にルートを保護できます。本記事では、JWT検証ロジックを含むAuthGuardの実装から、APP_GUARDによるグローバル登録、@Public()デコレータによる除外設定まで、実践的な認証ガードの構築方法を解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Node.js |
20以上 |
| npm |
10以上 |
| NestJS |
11.x |
| @nestjs/jwt |
11.x |
| TypeScript |
5.x |
| OS |
Windows / macOS / Linux |
| エディタ |
VS Code(推奨) |
事前に以下の準備を完了してください。
- NestJS CLIのインストール済み
- NestJSプロジェクトの作成済み
- JWTモジュールの設定済み(前回記事の内容)
- Guardの基本概念の理解
前回の記事NestJS JWT認証 - @nestjs/jwtでトークンベース認証を実装するでJWTモジュールの設定とトークン発行を実装していることを前提とします。Guardの基本についてはNestJSのGuard - CanActivateインターフェースでアクセス制御を実装するを参照してください。
認証ガードの全体像#
認証ガード(AuthGuard)は、リクエストに含まれるJWTトークンを検証し、認証済みユーザーのみがエンドポイントにアクセスできるようにする仕組みです。本記事で実装する認証システムの全体像を確認しましょう。
flowchart TB
subgraph Request["リクエスト処理フロー"]
A[クライアント] --> B[Authorization: Bearer token]
B --> C{AuthGuard}
C --> D{@Public?}
D -->|Yes| E[認証スキップ]
D -->|No| F{トークン検証}
F -->|有効| G[request.userに付与]
F -->|無効| H[401 Unauthorized]
E --> I[Controller]
G --> I
end
style C fill:#FF5722,color:#fff
style D fill:#2196F3,color:#fff
style F fill:#4CAF50,color:#fff認証ガードが解決する課題#
すべてのControllerメソッドで個別にトークン検証を行うと、以下の問題が発生します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 悪い例: 各メソッドで認証ロジックを重複実装
@Controller('users')
export class UsersController {
@Get()
async findAll(@Headers('authorization') auth: string) {
// トークン検証ロジックが重複
const token = auth?.split(' ')[1];
if (!token) throw new UnauthorizedException();
const payload = await this.jwtService.verifyAsync(token);
// ...
}
@Get(':id')
async findOne(@Headers('authorization') auth: string) {
// 同じ検証ロジックが繰り返し登場
const token = auth?.split(' ')[1];
if (!token) throw new UnauthorizedException();
const payload = await this.jwtService.verifyAsync(token);
// ...
}
}
|
AuthGuardを使用することで、この問題を解決できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 良い例: Guardで認証ロジックを一元化
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
@Get()
async findAll(@Request() req) {
// req.userから認証情報を取得
return this.usersService.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
}
|
JWT検証ロジックを含むAuthGuardの実装#
JWT検証を行うAuthGuardを実装します。CanActivateインターフェースを実装し、リクエストヘッダーからトークンを抽出して検証します。
AuthGuardの基本実装#
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
|
// src/auth/guards/auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { jwtConstants } from '../constants';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('認証トークンが必要です');
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
// リクエストオブジェクトにユーザー情報を付与
request['user'] = payload;
} catch {
throw new UnauthorizedException('無効または期限切れのトークンです');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
|
実装のポイント#
このAuthGuard実装における重要なポイントを解説します。
| 処理 |
説明 |
extractTokenFromHeader |
Authorization: Bearer <token>形式からトークンを抽出 |
jwtService.verifyAsync |
トークンの署名と有効期限を検証 |
request['user'] |
検証済みペイロードをリクエストに付与し、後続処理で利用可能に |
UnauthorizedException |
認証失敗時に401ステータスを返却 |
詳細なエラーハンドリング#
トークン検証時のエラーを詳細に分類することで、クライアントに適切なエラーメッセージを返却できます。
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
|
// src/auth/guards/auth.guard.ts(詳細なエラーハンドリング版)
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService, TokenExpiredError, JsonWebTokenError } from '@nestjs/jwt';
import { Request } from 'express';
import { jwtConstants } from '../constants';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('認証トークンが必要です');
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
request['user'] = payload;
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new UnauthorizedException('トークンの有効期限が切れています');
}
if (error instanceof JsonWebTokenError) {
throw new UnauthorizedException('無効なトークン形式です');
}
throw new UnauthorizedException('認証に失敗しました');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
|
エラータイプと対応するメッセージの対応表は以下の通りです。
| エラータイプ |
発生条件 |
エラーメッセージ |
TokenExpiredError |
expクレームの時刻を過ぎた |
トークンの有効期限が切れています |
JsonWebTokenError |
署名不一致、形式不正 |
無効なトークン形式です |
| その他のエラー |
予期しないエラー |
認証に失敗しました |
@UseGuards()によるルート保護#
@UseGuards()デコレータを使用して、Controller全体または個別のメソッドにAuthGuardを適用できます。
メソッドレベルでの適用#
特定のエンドポイントのみを保護する場合、メソッドレベルで@UseGuards()を適用します。
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
|
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '../auth/guards/auth.guard';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// 認証不要: ユーザー登録
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
// 認証必要: ユーザー一覧取得
@UseGuards(AuthGuard)
@Get()
findAll() {
return this.usersService.findAll();
}
// 認証必要: プロフィール取得
@UseGuards(AuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
}
|
Controllerレベルでの適用#
Controller内のすべてのエンドポイントを保護する場合、Controllerレベルで適用します。
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
|
// src/admin/admin.controller.ts
import {
Controller,
Get,
Post,
Delete,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/guards/auth.guard';
import { AdminService } from './admin.service';
@Controller('admin')
@UseGuards(AuthGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('dashboard')
getDashboard() {
return this.adminService.getDashboardStats();
}
@Get('users')
getUsers() {
return this.adminService.getAllUsers();
}
@Delete('users/:id')
deleteUser(@Param('id') id: string) {
return this.adminService.deleteUser(+id);
}
}
|
複数のGuardを組み合わせる#
複数のGuardを同時に適用する場合、順番に評価されます。すべてのGuardがtrueを返した場合のみ、リクエストは処理されます。
1
2
3
4
5
6
|
// 複数のGuardを適用
@UseGuards(AuthGuard, RolesGuard)
@Get('admin-only')
adminOnly() {
return { message: 'Admin only resource' };
}
|
flowchart LR
A[リクエスト] --> B[AuthGuard]
B -->|true| C[RolesGuard]
C -->|true| D[Controller]
B -->|false| E[401 Unauthorized]
C -->|false| F[403 Forbidden]APP_GUARDによるグローバルGuard登録#
多くのエンドポイントを保護する必要がある場合、APP_GUARDを使用してグローバルにGuardを登録し、デフォルトですべてのルートを保護できます。
グローバルGuardの登録方法#
AuthModuleでAPP_GUARDプロバイダを設定します。
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
|
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
import { UsersModule } from '../users/users.module';
import { jwtConstants } from './constants';
@Module({
imports: [
UsersModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController],
providers: [
AuthService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
exports: [AuthService],
})
export class AuthModule {}
|
グローバルGuard登録の仕組み#
APP_GUARDトークンを使用することで、NestJSはこのGuardをすべてのルートに自動的に適用します。
| 登録方法 |
依存性注入 |
適用範囲 |
app.useGlobalGuards() |
不可 |
アプリケーション全体 |
APP_GUARDプロバイダ |
可能 |
アプリケーション全体 |
app.useGlobalGuards()と異なり、APP_GUARDを使用した登録では依存性注入が利用可能です。JwtServiceなどの他のサービスをAuthGuardに注入できるため、この方法を推奨します。
グローバルGuard登録時の課題#
グローバルGuardを登録すると、すべてのエンドポイントが保護されます。しかし、ログインエンドポイントや公開APIなど、認証不要なルートも存在します。
flowchart TB
subgraph Problem["グローバルGuardの課題"]
A[全エンドポイント保護] --> B["POST /auth/login"]
A --> C["POST /auth/register"]
A --> D["GET /health"]
A --> E["GET /users/:id"]
B --> F["認証不要だが<br>ブロックされる"]
C --> F
D --> F
end
style F fill:#f44336,color:#fffこの課題を解決するため、@Public()デコレータによる除外設定が必要です。
@Public()デコレータによる除外設定#
グローバルGuardを使用しながら、特定のルートを認証から除外する仕組みを実装します。
@Public()デコレータの作成#
カスタムデコレータを作成し、メタデータを設定します。
1
2
3
4
5
|
// src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
SetMetadataはハンドラにカスタムメタデータを付与するためのヘルパー関数です。ここではisPublicというキーにtrueを設定しています。
AuthGuardでのメタデータ確認#
Reflectorを使用してメタデータを読み取り、@Public()が付与されている場合は認証をスキップします。
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
|
// src/auth/guards/auth.guard.ts(@Public()対応版)
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService, TokenExpiredError, JsonWebTokenError } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { jwtConstants } from '../constants';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// @Public()デコレータの確認
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('認証トークンが必要です');
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
request['user'] = payload;
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new UnauthorizedException('トークンの有効期限が切れています');
}
if (error instanceof JsonWebTokenError) {
throw new UnauthorizedException('無効なトークン形式です');
}
throw new UnauthorizedException('認証に失敗しました');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
|
Reflector.getAllAndOverride()の動作#
getAllAndOverride()メソッドは、指定されたメタデータキーを以下の優先順位で検索します。
- ハンドラ(メソッド)レベルのメタデータ
- クラス(Controller)レベルのメタデータ
最初に見つかった値を返すため、メソッドレベルの設定がControllerレベルの設定を上書きします。
flowchart TB
A["getAllAndOverride()"] --> B{ハンドラに<br>メタデータあり?}
B -->|Yes| C[ハンドラの値を返す]
B -->|No| D{クラスに<br>メタデータあり?}
D -->|Yes| E[クラスの値を返す]
D -->|No| F[undefinedを返す]@Public()デコレータの使用例#
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
|
// src/auth/auth.controller.ts
import {
Body,
Controller,
Get,
Post,
HttpCode,
HttpStatus,
Request,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInDto } from './dto/sign-in.dto';
import { Public } from './decorators/public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: SignInDto) {
return this.authService.signIn(signInDto.username, signInDto.password);
}
@Public()
@Post('register')
register(@Body() signInDto: SignInDto) {
return this.authService.register(signInDto.username, signInDto.password);
}
// 認証必要: グローバルGuardが適用される
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
}
|
ヘルスチェックエンドポイントの公開#
ロードバランサーやKubernetesのヘルスチェック用エンドポイントも@Public()で公開します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
@Controller('health')
export class HealthController {
@Public()
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
@Public()
@Get('ready')
readiness() {
return { status: 'ready' };
}
}
|
完成したプロジェクト構造#
認証ガードの実装が完了したプロジェクト構造は以下の通りです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
src/
├── auth/
│ ├── decorators/
│ │ └── public.decorator.ts
│ ├── dto/
│ │ └── sign-in.dto.ts
│ ├── guards/
│ │ └── auth.guard.ts
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ ├── auth.service.ts
│ └── constants.ts
├── health/
│ └── health.controller.ts
├── users/
│ ├── users.module.ts
│ └── users.service.ts
└── app.module.ts
|
動作確認#
実装した認証ガードの動作を確認しましょう。
アプリケーションの起動#
公開エンドポイントのテスト#
1
2
3
4
|
# ログイン(@Public()で公開)
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
|
期待される結果:
1
2
3
|
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
|
保護されたエンドポイントのテスト#
1
2
|
# トークンなしでアクセス(認証エラー)
curl http://localhost:3000/auth/profile
|
期待される結果:
1
2
3
4
5
|
{
"statusCode": 401,
"message": "認証トークンが必要です",
"error": "Unauthorized"
}
|
1
2
3
4
5
|
# トークンを使用してアクセス(成功)
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
curl http://localhost:3000/auth/profile \
-H "Authorization: Bearer $TOKEN"
|
期待される結果:
1
2
3
4
5
6
|
{
"sub": 1,
"username": "admin",
"iat": 1704234567,
"exp": 1704238167
}
|
ヘルスチェックのテスト#
1
2
|
# ヘルスチェック(@Public()で公開)
curl http://localhost:3000/health
|
期待される結果:
1
2
3
4
|
{
"status": "ok",
"timestamp": "2026-01-06T13:00:00.000Z"
}
|
認証設計のベストプラクティス#
グローバルGuardと@Public()デコレータを使用する際のベストプラクティスを紹介します。
デフォルト保護の原則#
新しいエンドポイントを追加する際、デフォルトで認証が必要になるため、セキュリティ上のミスを防げます。
| アプローチ |
メリット |
デメリット |
| デフォルト保護 + @Public()除外 |
新規ルートが自動的に保護される |
公開ルートごとに明示的な設定が必要 |
| デフォルト公開 + @UseGuards()保護 |
公開ルートの設定が不要 |
保護し忘れのリスク |
セキュリティを重視する場合は、デフォルト保護のアプローチを推奨します。
@Public()を使用すべきエンドポイント#
以下のエンドポイントは通常@Public()で公開します。
POST /auth/login - ログイン
POST /auth/register - ユーザー登録
POST /auth/forgot-password - パスワードリセットリクエスト
GET /health - ヘルスチェック
GET /health/ready - レディネスチェック
GET /docs - API仕様書(Swagger)
認証情報へのアクセス#
認証済みリクエストでは、request.userからユーザー情報にアクセスできます。型安全性を高めるため、カスタムデコレータの作成を検討してください。
1
2
3
4
5
6
7
8
9
10
|
// src/auth/decorators/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
|
使用例:
1
2
3
4
5
6
7
8
9
|
@Get('profile')
getProfile(@User() user: JwtPayload) {
return user;
}
@Get('me')
getUsername(@User('username') username: string) {
return { username };
}
|
まとめ#
本記事では、NestJSでJWT検証ロジックを含むAuthGuardの実装方法を解説しました。
学習した主要なポイントを振り返りましょう。
CanActivateインターフェースを実装したJWT検証ロジックの構築
@UseGuards()によるメソッド・Controllerレベルでのルート保護
APP_GUARDプロバイダによるグローバルGuard登録
@Public()カスタムデコレータによる認証除外設定
Reflector.getAllAndOverride()によるメタデータの読み取り
グローバルGuardと@Public()デコレータを組み合わせることで、デフォルトでセキュアなAPIを構築しつつ、必要なエンドポイントのみを公開できます。次のステップとして、NestJS RBAC実装 - カスタムデコレータとGuardでロール管理するの記事で、ロールベースのアクセス制御について学ぶことをおすすめします。
参考リンク#