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つのフェーズが存在します。
- 認証フェーズ: ユーザーがログインしてJWTトークンを取得
- 認可フェーズ: トークンを使用して保護されたリソースにアクセス
本記事では、主に認証フェーズに焦点を当て、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);
}
}
|
UsersModuleでUsersServiceをエクスポートし、他のモジュールから利用可能にします。
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が提供するJwtModuleをAuthModuleに登録します。
シークレットキーの定義#
JWTの署名に使用する秘密鍵を定数として定義します。
1
2
3
4
|
// src/auth/constants.ts
export const jwtConstants = {
secret: 'DO_NOT_USE_THIS_VALUE_IN_PRODUCTION',
};
|
本番環境では、この値を環境変数やシークレット管理サービスから取得してください。ソースコードにハードコーディングしてはいけません。
JwtModule.register()による設定#
AuthModuleでJwtModuleを設定します。
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_SECRETとJWT_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の実装#
AuthServiceでJwtServiceを注入し、ログイン処理とトークン生成を実装します。
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-validatorとclass-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は以下の処理を行います。
AuthorizationヘッダーからBearerトークンを抽出
verifyAsync()でトークンの署名と有効期限を検証
- 検証成功時、デコードされたペイロードをリクエストオブジェクトに付与
- 検証失敗時、
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認証を本番環境にデプロイする際は、以下の点に注意してください。
セキュリティに関する推奨事項#
- シークレットキーの管理: 環境変数またはシークレット管理サービスから取得する
- パスワードのハッシュ化: bcryptを使用してソルト付きハッシュで保存する
- HTTPS必須: トークンの盗聴を防ぐため、本番環境では必ずHTTPSを使用する
- 適切な有効期限: アクセストークンは短め(15分〜1時間)、リフレッシュトークンで更新する
- 監査ログ: 認証試行の記録を残す
推奨される追加実装#
- リフレッシュトークンによるトークンローテーション
- レート制限(Rate Limiting)によるブルートフォース攻撃対策
- ブラックリスト機能によるトークン無効化
まとめ#
本記事では、NestJSで@nestjs/jwtパッケージを使用したJWT認証の実装方法を解説しました。
学習した主要なポイントを振り返りましょう。
@nestjs/jwtパッケージのインストールとJwtModule.register()による設定
JwtServiceのsignAsync()とverifyAsync()によるトークン操作
- ログインエンドポイントの実装とトークン発行フロー
AuthGuardによるトークン検証と保護されたルートの実装
- エラーハンドリングと本番環境でのセキュリティ考慮事項
次のステップとして、AuthGuardによるルート保護の記事で、グローバルGuardの設定やロールベースのアクセス制御について学ぶことをおすすめします。
参考リンク#