NestJSでステートレスな認証システムを構築する際、JWT(JSON Web Token)は最も広く採用されている手法です。@nestjs/jwtパッケージを使用することで、トークンの生成・検証をシンプルかつ安全に実装できます。本記事では、JWTモジュールの設定からログインエンドポイントの作成、トークンの発行・検証フローまで、実践的なJWT認証の実装方法を解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Node.js 20以上
npm 10以上
NestJS 11.x
@nestjs/jwt 11.x
TypeScript 5.x
OS Windows / macOS / Linux
エディタ VS Code(推奨)

事前に以下の準備を完了してください。

  • NestJS CLIのインストール済み
  • NestJSプロジェクトの作成済み
  • Module、Controller、Service、Guardの基本理解

NestJSプロジェクトの作成方法はNestJS入門記事を参照してください。JWTの仕組みと構造についてはJWTの仕組みと構造を徹底解説で詳しく解説しています。

JWTによる認証フローの概要

JWT認証の基本的なフローを確認しましょう。NestJSでの実装に入る前に、全体像を把握することが重要です。

sequenceDiagram
    participant C as クライアント
    participant S as NestJSサーバー
    participant DB as ユーザーDB

    C->>S: POST /auth/login (username, password)
    S->>DB: ユーザー検索
    DB-->>S: ユーザー情報
    S->>S: パスワード検証
    S->>S: JWTトークン生成
    S-->>C: { access_token: "eyJhbG..." }
    
    Note over C,S: 以降のリクエストではトークンを使用
    
    C->>S: GET /protected (Authorization: Bearer token)
    S->>S: トークン検証
    S-->>C: 保護されたリソース

このフローでは、以下の2つのフェーズが存在します。

  1. 認証フェーズ: ユーザーがログインしてJWTトークンを取得
  2. 認可フェーズ: トークンを使用して保護されたリソースにアクセス

本記事では、主に認証フェーズに焦点を当て、JWTトークンの生成と検証を実装します。

@nestjs/jwtパッケージのインストール

まず、NestJSでJWTを扱うための公式パッケージをインストールします。

1
npm install --save @nestjs/jwt

@nestjs/jwtは内部でjsonwebtokenライブラリを使用しており、NestJSの依存性注入システムに統合された形でJWT操作を提供します。

インストール後、package.jsonに以下が追加されていることを確認してください。

1
2
3
4
5
{
  "dependencies": {
    "@nestjs/jwt": "^11.0.2"
  }
}

認証モジュールの作成

認証機能をモジュールとして整理します。Nest CLIを使用して必要なファイルを生成しましょう。

1
2
3
4
5
6
7
8
# 認証モジュール、コントローラー、サービスの生成
nest g module auth
nest g controller auth
nest g service auth

# ユーザーモジュールとサービスの生成
nest g module users
nest g service users

これにより、以下のディレクトリ構造が作成されます。

1
2
3
4
5
6
7
8
9
src/
├── auth/
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   └── auth.service.ts
├── users/
│   ├── users.module.ts
│   └── users.service.ts
└── app.module.ts

ユーザーサービスの実装

まず、ユーザー情報を管理するUsersServiceを実装します。本番環境ではデータベースと連携しますが、ここでは学習のためにインメモリでユーザーを管理します。

 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
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User {
  userId: number;
  username: string;
  password: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    {
      userId: 1,
      username: 'admin',
      password: 'admin123', // 本番環境では必ずハッシュ化すること
    },
    {
      userId: 2,
      username: 'user',
      password: 'user123',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find((user) => user.username === username);
  }
}

UsersModuleUsersServiceをエクスポートし、他のモジュールから利用可能にします。

1
2
3
4
5
6
7
8
9
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

本記事のサンプルコードでは、パスワードを平文で保存していますが、実際のアプリケーションでは必ずbcryptなどを使用してハッシュ化してください。

JwtModuleの設定

@nestjs/jwtが提供するJwtModuleAuthModuleに登録します。

シークレットキーの定義

JWTの署名に使用する秘密鍵を定数として定義します。

1
2
3
4
// src/auth/constants.ts
export const jwtConstants = {
  secret: 'DO_NOT_USE_THIS_VALUE_IN_PRODUCTION',
};

本番環境では、この値を環境変数やシークレット管理サービスから取得してください。ソースコードにハードコーディングしてはいけません。

JwtModule.register()による設定

AuthModuleJwtModuleを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
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],
  exports: [AuthService],
})
export class AuthModule {}

JwtModule.register()の主要なオプションを確認しましょう。

オプション 説明
global trueにすると、他のモジュールでインポート不要になる true
secret HMAC系アルゴリズムで使用する秘密鍵 'my-secret-key'
signOptions トークン生成時のオプション { expiresIn: '1h' }
privateKey RSA/ECDSA用の秘密鍵 PEM形式の文字列
publicKey RSA/ECDSA用の公開鍵 PEM形式の文字列

非同期設定(registerAsync)

設定を環境変数から動的に読み込む場合は、registerAsync()を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/auth/auth.module.ts(非同期設定の例)
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_EXPIRES_IN', '1h'),
        },
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AuthModule {}

この方法により、環境変数JWT_SECRETJWT_EXPIRES_INから設定を読み込めます。

JwtServiceによるトークン操作

JwtService@nestjs/jwtが提供する中核サービスで、トークンの生成・検証・デコードを担当します。

JwtServiceの主要メソッド

メソッド 説明 戻り値
sign(payload, options?) 同期的にトークンを生成 string
signAsync(payload, options?) 非同期的にトークンを生成 Promise<string>
verify(token, options?) 同期的にトークンを検証 デコードされたペイロード
verifyAsync(token, options?) 非同期的にトークンを検証 Promise<ペイロード>
decode(token, options?) トークンをデコード(検証なし) object | string

AuthServiceの実装

AuthServiceJwtServiceを注入し、ログイン処理とトークン生成を実装します。

 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
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async signIn(
    username: string,
    pass: string,
  ): Promise<{ access_token: string }> {
    // ユーザーを検索
    const user = await this.usersService.findOne(username);

    // パスワードを検証(本番環境ではbcrypt.compareを使用)
    if (user?.password !== pass) {
      throw new UnauthorizedException('認証情報が正しくありません');
    }

    // JWTペイロードを作成
    const payload = {
      sub: user.userId,
      username: user.username,
    };

    // トークンを生成して返却
    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }

  async validateToken(token: string): Promise<any> {
    try {
      return await this.jwtService.verifyAsync(token);
    } catch {
      throw new UnauthorizedException('無効なトークンです');
    }
  }
}

ポイントを解説します。

  • sub(subject)クレームにユーザーIDを設定するのは、JWT標準に従った慣例です
  • signAsync()は非同期でトークンを生成し、動的なシークレットプロバイダーにも対応しています
  • パスワード検証は本番環境では必ずbcrypt.compare()を使用してください

ログインエンドポイントの実装

AuthControllerでログインエンドポイントを公開します。

DTOの定義

リクエストボディの型安全性を確保するため、DTOを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// src/auth/dto/sign-in.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';

export class SignInDto {
  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}

DTOでバリデーションを使用する場合は、class-validatorclass-transformerをインストールし、ValidationPipeを有効にしてください。詳細はPipeによるバリデーション記事を参照してください。

AuthControllerの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/auth/auth.controller.ts
import {
  Body,
  Controller,
  Post,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInDto } from './dto/sign-in.dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: SignInDto) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }
}

@HttpCode(HttpStatus.OK)を指定する理由は、@Post()のデフォルトステータスコードが201 Createdであるためです。ログイン処理はリソースの作成ではないため、200 OKを返すのが適切です。

動作確認

アプリケーションを起動し、ログインエンドポイントをテストしましょう。

1
2
# アプリケーションの起動
npm run start:dev

curlでのテスト

1
2
3
4
# 正しい認証情報でログイン
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "admin123"}'

期待される結果:

1
2
3
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MDQyMzQ1NjcsImV4cCI6MTcwNDIzODE2N30.xxxxx"
}

誤った認証情報でのテスト

1
2
3
4
# 誤ったパスワードでログイン
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "wrongpassword"}'

期待される結果:

1
2
3
4
5
{
  "statusCode": 401,
  "message": "認証情報が正しくありません",
  "error": "Unauthorized"
}

トークンの構造を確認する

発行されたトークンをデコードして、構造を確認してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// トークンのデコード例
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const decoded = jwtService.decode(token);
console.log(decoded);
// {
//   sub: 1,
//   username: 'admin',
//   iat: 1704234567,  // 発行日時
//   exp: 1704238167   // 有効期限
// }

decode()メソッドは署名の検証を行わずにペイロードを取得します。検証が必要な場合は必ずverify()またはverifyAsync()を使用してください。

flowchart LR
    subgraph JWT["発行されたトークン"]
        H["Header<br>alg: HS256<br>typ: JWT"]
        P["Payload<br>sub: 1<br>username: admin<br>iat: ...<br>exp: ..."]
        S["Signature<br>HMACSHA256(...)"]
    end
    H --- P --- S

トークン検証の実装

発行されたトークンを検証し、保護されたリソースへのアクセスを制御する方法を実装します。

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/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;
  }
}

このGuardは以下の処理を行います。

  1. AuthorizationヘッダーからBearerトークンを抽出
  2. verifyAsync()でトークンの署名と有効期限を検証
  3. 検証成功時、デコードされたペイロードをリクエストオブジェクトに付与
  4. 検証失敗時、UnauthorizedExceptionをスロー

保護されたエンドポイントの作成

 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
// src/auth/auth.controller.ts に追加
import {
  Body,
  Controller,
  Get,
  Post,
  HttpCode,
  HttpStatus,
  UseGuards,
  Request,
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
import { SignInDto } from './dto/sign-in.dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: SignInDto) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }

  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

保護されたエンドポイントのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# トークンなしでアクセス(失敗)
curl http://localhost:3000/auth/profile
# {"statusCode":401,"message":"トークンが必要です","error":"Unauthorized"}

# ログインしてトークンを取得
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "admin123"}' | jq -r '.access_token')

# トークンを使用してアクセス(成功)
curl http://localhost:3000/auth/profile \
  -H "Authorization: Bearer $TOKEN"
# {"sub":1,"username":"admin","iat":...,"exp":...}

signOptionsの詳細設定

JwtModule.register()signAsync()で指定可能な主要オプションを解説します。

有効期限の設定

1
2
3
4
5
6
7
8
JwtModule.register({
  secret: jwtConstants.secret,
  signOptions: {
    expiresIn: '1h',        // 1時間
    // expiresIn: '7d',     // 7日
    // expiresIn: 3600,     // 秒数でも指定可能
  },
})

その他のsignOptions

オプション 説明
expiresIn トークンの有効期限 '1h', '7d', 3600
notBefore トークンが有効になるまでの時間 '10s'
audience トークンの対象者 'https://api.example.com'
issuer トークンの発行者 'https://auth.example.com'
jwtid トークンの一意識別子 'unique-id-123'
subject トークンの主題 'user-id'
algorithm 署名アルゴリズム 'HS256', 'RS256'

動的なオプション指定

signAsync()呼び出し時に個別のオプションを指定することもできます。

1
2
3
4
5
6
7
8
// 特定のトークンに異なる有効期限を設定
const shortLivedToken = await this.jwtService.signAsync(payload, {
  expiresIn: '15m',
});

const longLivedToken = await this.jwtService.signAsync(payload, {
  expiresIn: '30d',
});

エラーハンドリング

JWT関連のエラーを適切に処理する方法を解説します。

一般的なエラーパターン

 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
// src/auth/auth.guard.ts(詳細なエラーハンドリング)
import { JsonWebTokenError, TokenExpiredError } from '@nestjs/jwt';

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;
}

主なエラータイプは以下の通りです。

エラータイプ 発生条件
TokenExpiredError トークンの有効期限切れ
JsonWebTokenError トークン形式の不正、署名の不一致
NotBeforeError nbfクレームの時刻に達していない

完成したプロジェクト構造

最終的なプロジェクト構造は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
src/
├── auth/
│   ├── dto/
│   │   └── sign-in.dto.ts
│   ├── auth.controller.ts
│   ├── auth.guard.ts
│   ├── auth.module.ts
│   ├── auth.service.ts
│   └── constants.ts
├── users/
│   ├── users.module.ts
│   └── users.service.ts
└── app.module.ts

本番環境での注意点

JWT認証を本番環境にデプロイする際は、以下の点に注意してください。

セキュリティに関する推奨事項

  1. シークレットキーの管理: 環境変数またはシークレット管理サービスから取得する
  2. パスワードのハッシュ化: bcryptを使用してソルト付きハッシュで保存する
  3. HTTPS必須: トークンの盗聴を防ぐため、本番環境では必ずHTTPSを使用する
  4. 適切な有効期限: アクセストークンは短め(15分〜1時間)、リフレッシュトークンで更新する
  5. 監査ログ: 認証試行の記録を残す

推奨される追加実装

  • リフレッシュトークンによるトークンローテーション
  • レート制限(Rate Limiting)によるブルートフォース攻撃対策
  • ブラックリスト機能によるトークン無効化

まとめ

本記事では、NestJSで@nestjs/jwtパッケージを使用したJWT認証の実装方法を解説しました。

学習した主要なポイントを振り返りましょう。

  • @nestjs/jwtパッケージのインストールとJwtModule.register()による設定
  • JwtServicesignAsync()verifyAsync()によるトークン操作
  • ログインエンドポイントの実装とトークン発行フロー
  • AuthGuardによるトークン検証と保護されたルートの実装
  • エラーハンドリングと本番環境でのセキュリティ考慮事項

次のステップとして、AuthGuardによるルート保護の記事で、グローバルGuardの設定やロールベースのアクセス制御について学ぶことをおすすめします。

参考リンク