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の登録方法

AuthModuleAPP_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()メソッドは、指定されたメタデータキーを以下の優先順位で検索します。

  1. ハンドラ(メソッド)レベルのメタデータ
  2. クラス(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
npm run start:dev

公開エンドポイントのテスト

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でロール管理するの記事で、ロールベースのアクセス制御について学ぶことをおすすめします。

参考リンク