NestJS Guard / JWT認証
loginでJWTを発行し、JwtAuthGuardで保護エンドポイントを作る(Passport JWTパターン)
NestJSの例外処理まで終えている前提。
この記事では、既存のユーザー管理APIにログイン機能を追加し、JWTがないと見られないAPIを作る。
この記事でやること
UserにpasswordHashを追加するPOST /auth/loginでJWTを発行するJwtStrategyとJwtAuthGuardでBearerトークンを検証するGET /users/meを認証必須APIにする
平文パスワードはDBに保存しない。ユーザー作成時に bcrypt でハッシュ化して、DBには passwordHash だけを保存する。
触るファイル一覧
api-sample/
├─ .env # JWT_SECRETを追加
├─ prisma/
│ └─ schema.prisma # passwordHashを追加
└─ src/
├─ app.module.ts # AuthModuleを追加
├─ auth/ # 新規作成
│ ├─ auth.controller.ts
│ ├─ auth.module.ts
│ ├─ auth.service.ts
│ ├─ dto/
│ │ └─ login.dto.ts
│ ├─ jwt-auth.guard.ts
│ └─ jwt.strategy.ts
└─ users/
├─ dto/
│ └─ create-user.dto.ts # passwordを追加
├─ users.controller.ts # /users/meを追加
└─ users.service.ts # passwordHash保存に変更1. パッケージを追加する
pnpmの場合。
pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
pnpm add -D @types/passport-jwt @types/bcryptnpmの場合。
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt2. schema.prisma に passwordHash を追加する
編集するファイルは api-sample/prisma/schema.prisma。
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String // 追加: 平文ではなくハッシュ化したパスワードを保存する
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}既存データがある状態で passwordHash String を追加すると、既存行に値が無いためマイグレーションで止まることがある。例えば、すでに User が1件ある状態で必須カラムを追加すると、Prismaは「既存の1件に入れる passwordHash が分からない」と判断する。
Added the required column `passwordHash` to the `User` table without a default value.
There are 1 rows in this table, it is not possible to execute this step.この場合は AかBのどちらか一方 を選ぶ。両方やる必要はない。
A. 学習用なのでDBを消してよい場合
今回のサンプルではこちらが一番簡単。DB内のデータを消してよいなら、SQLite DBをリセットしてからマイグレーションする。
実行前にNode.jsのバージョンを確認する。
node -vPrisma 7はNode.js 20.19.0 以上が前提。v20.15.1 のような古い20系だと、@prisma/dev や ERR_REQUIRE_ESM で失敗することがある。先にNode.js 22系、または20.19以上へ上げてから依存関係を入れ直す。
# nvmを使っている場合の例
nvm install 22
nvm use 22
node -v
pnpm install
pnpm rebuild better-sqlite3Node.jsが 20.19.0 以上になってから、次を実行する。
pnpm exec prisma migrate reset
pnpm exec prisma migrate dev --name add_password_hash
pnpm exec prisma generatemigrate reset はDB内のデータを削除する。今回のような学習用サンプルなら問題になりにくいが、残したいデータがある場合は使わない。
npmの場合は次。
npx prisma migrate reset
npx prisma migrate dev --name add_password_hash
npx prisma generateB. 既存データを残したい場合
既存データを残したい場合は、いきなり必須カラムにしない。たとえば次のように一度nullableで追加する。
passwordHash String?pnpm exec prisma migrate dev --name add_password_hash_nullable
pnpm exec prisma generateその後、既存ユーザーに仮の passwordHash を入れる処理を行い、全行に値が入ってから passwordHash String に戻す。実務ではこのように「カラム追加 → 既存データの埋め戻し → 必須化」の順で進めることが多い。
この記事のサンプルでは、以降は Aの手順で passwordHash String を追加した前提 で進める。
pnpx prisma ... は一時実行用のパッケージを取りに行くため、プロジェクトに入っている prisma と別のものが動くことがある。Prismaを devDependencies に入れている前提なら、pnpmでは pnpm exec prisma ... を使う方が安全。
ERR_REQUIRE_ESM や @prisma/dev まわりのエラーが出る場合、pnpm exec でもNode.jsが古いと失敗する。エラーログ末尾に Node.js v20.15.1 のように出ている場合は、コマンドではなくNode.jsバージョンを先に直す。
3. .env にJWT秘密鍵を追加する
編集するファイルは api-sample/.env。
DATABASE_URL="file:./prisma/dev.db"
JWT_SECRET="dev-secret-change-me"学習用なので短い文字列でも動くが、実務では十分に長いランダム文字列を使い、Gitにコミットしない。
4. CreateUserDto に password を追加する
編集するファイルは api-sample/src/users/dto/create-user.dto.ts。
import { IsEmail, IsString, Length } from "class-validator";
export class CreateUserDto {
@IsEmail()
email!: string;
@IsString()
@Length(1, 50)
name!: string;
// 追加: 作成時だけ平文passwordを受け取り、Serviceでハッシュ化する
@IsString()
@Length(8, 72)
password!: string;
}password はAPI入力として受け取るだけで、DBにはそのまま保存しない。
5. UsersService でパスワードをハッシュ化する
編集するファイルは api-sample/src/users/users.service.ts。
create の中で password を取り出し、bcrypt.hash で passwordHash を作る。
import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Prisma } from "../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import * as bcrypt from "bcrypt";
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.user.findMany({
orderBy: { createdAt: "desc" },
// 変更: passwordHashをレスポンスに含めない
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
}
async findOne(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
// 変更: passwordHashをレスポンスに含めない
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
if (!user) {
throw new NotFoundException("User not found");
}
return user;
}
async create(dto: CreateUserDto) {
const { password, ...data } = dto;
const passwordHash = await bcrypt.hash(password, 10);
try {
return await this.prisma.user.create({
// 変更: passwordではなくpasswordHashを保存する
data: { ...data, passwordHash },
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
throw new ConflictException("Email already in use");
}
throw e;
}
}
async update(id: string, dto: UpdateUserDto) {
await this.findOne(id);
return this.prisma.user.update({
where: { id },
data: dto,
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
}
async remove(id: string) {
await this.findOne(id);
return this.prisma.user.delete({
where: { id },
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
}
}重要なのは、レスポンスに passwordHash を返さないこと。select で返すフィールドを明示している。
passwordHash は平文パスワードではないが、外部に出してよい情報でもない。もしAPIレスポンスに含めると、フロントエンド、ブラウザの開発者ツール、ログ、監視ツール、プロキシなどに残る可能性がある。攻撃者が passwordHash を手に入れると、手元で候補パスワードを大量に試す オフライン攻撃 の材料になる。
つまり、passwordHash は「復号できないから安全」ではなく、漏らさない前提で扱う秘密寄りのデータ と考える。詳しくは パスワードハッシュ化の基礎 でも整理している。
6. 認証用DTOを作る
新規作成するファイルは api-sample/src/auth/dto/login.dto.ts。
LoginDto は、POST /auth/login で受け取るリクエストBodyの形を表す。
ユーザー作成用の CreateUserDto は email / name / password を受け取るが、ログインでは name は不要。ログイン専用のDTOを分けることで、「このAPIには何を送ればよいか」が明確になる。
また、ここで @IsEmail() や @Length() を付けることで、Serviceに入る前に最低限の入力チェックができる。認証失敗(パスワード不一致)と、入力形式エラー(メール形式ではない)は別の問題なので、DTOで入口を分けておく。
import { IsEmail, IsString, Length } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@Length(8, 72)
password!: string;
}7. AuthService を作る
新規作成するファイルは api-sample/src/auth/auth.service.ts。
AuthService は、認証の中身を担当するService。Controllerにメール照合やパスワード比較を書くと、HTTPの入口と認証ロジックが混ざって読みにくくなるため、ここに切り出す。
このServiceでやることは大きく3つ。
emailからユーザーを探すbcrypt.compareで入力パスワードとpasswordHashを照合する- 認証できたら
JwtServiceでJWTを発行する
メールが存在しない場合とパスワードが違う場合は、どちらも同じ UnauthorizedException にする。どちらが間違っているかを細かく返すと、攻撃者に「このメールアドレスは登録されている」と推測されやすくなるため。
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../prisma/prisma.service";
import { LoginDto } from "./dto/login.dto";
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
) {}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
// 追加: メール不一致・パスワード不一致のどちらも同じ401にする
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
throw new UnauthorizedException("Invalid email or password");
}
const accessToken = await this.jwtService.signAsync({
sub: user.id,
email: user.email,
});
return { accessToken };
}
}sub はJWTで「主体(subject)」を表す一般的なフィールド。ここではユーザーIDを入れている。
8. AuthController を作る
新規作成するファイルは api-sample/src/auth/auth.controller.ts。
AuthController は、ログインAPIのHTTP入口。
ここでは POST /auth/login を受け取り、リクエストBodyを LoginDto として受け取って、実際の認証処理は AuthService.login() に任せる。
Controllerの責務は、URL・HTTPメソッド・リクエストBodyの受け取りを定義すること。メール照合やJWT発行のような処理はService側に置く。
import { Body, Controller, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("login")
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
}これで POST /auth/login が生える。
9. JwtStrategy を作る
新規作成するファイルは api-sample/src/auth/jwt.strategy.ts。
JwtStrategy は、「リクエストに付いてきたJWTをどう検証するか」をPassportに教える設定。
Authorization: Bearer <token> からJWTを取り出し、JWT_SECRET で署名を検証し、問題なければpayloadを req.user に変換する。
ログイン時にJWTを 発行する のは AuthService。
APIアクセス時にJWTを 検証する のが JwtStrategy。この2つは役割が違う。
コード上では、super({ ... }) の中が「JWTをどこから取り出し、どう検証するか」の設定で、validate() が「検証済みpayloadをアプリで使いやすい形に変換する処理」になる。
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
type JwtPayload = {
sub: string;
email: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
constructor() {
super({
// 追加: Authorization: Bearer <token> からJWT本体を取り出す
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// 追加: exp(有効期限)切れのJWTを拒否する
ignoreExpiration: false,
// 追加: AuthServiceでJWT発行に使った秘密鍵と同じ値で署名を検証する
secretOrKey: process.env.JWT_SECRET ?? "dev-secret-change-me",
});
}
// 追加: 署名と期限の検証が通った後に呼ばれる
// 戻り値は req.user に入る
validate(payload: JwtPayload) {
// AuthServiceで signAsync({ sub: user.id, email: user.email }) した値が payload に入る
return { userId: payload.sub, email: payload.email };
}
}このコードで起きていることを分解すると次の通り。
ExtractJwt.fromAuthHeaderAsBearerToken()…Authorization: Bearer <token>から<token>部分だけを取り出すignoreExpiration: false… JWTのexpが切れていたら拒否するsecretOrKey… JWT発行時と同じ秘密鍵で署名を検証するvalidate(payload)… 検証済みpayloadを受け取り、Controllerから使いやすいreq.userの形にする
10. JwtAuthGuard を作る
新規作成するファイルは api-sample/src/auth/jwt-auth.guard.ts。
JwtAuthGuard は、Controllerの各エンドポイントに付ける「認証ゲート」。
@UseGuards(JwtAuthGuard) を付けたAPIでは、リクエストがControllerメソッドに届く前に JwtStrategy によるJWT検証が走る。
つまり、JwtStrategy が「JWTの検証方法」、JwtAuthGuard が「このAPIでその検証を使うための入口」と考えると分かりやすい。
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}AuthGuard("jwt") の "jwt" は、JwtStrategy の戦略名と揃える。
11. AuthModule を作る
新規作成するファイルは api-sample/src/auth/auth.module.ts。
AuthModule は、認証機能の配線をまとめるModule。
NestJSでは、ControllerやServiceを作っただけではアプリに登録されない。AuthController、AuthService、JwtStrategy、JwtModule、PassportModule をこのModuleにまとめることで、認証機能として動くようにする。
ここで JwtModule.register() しているのは、AuthService の JwtService がJWTを発行できるようにするため。PassportModule と JwtStrategy は、Bearerトークンを検証するために使う。
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? "dev-secret-change-me",
signOptions: { expiresIn: "1h" },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}実務では @nestjs/config を使って JWT_SECRET を管理することが多い。ここでは学習用に process.env とフォールバック値で進める。
12. AppModule に AuthModule を追加する
編集するファイルは api-sample/src/app.module.ts。
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module"; // 追加
import { PrismaModule } from "./prisma/prisma.module";
import { UsersModule } from "./users/users.module";
@Module({
imports: [PrismaModule, UsersModule, AuthModule], // 変更: AuthModuleを追加
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}AuthModule を imports に入れないと、POST /auth/login はアプリに登録されない。
13. users.controller.ts に認証必須APIを追加する
編集するファイルは api-sample/src/users/users.controller.ts。
既存のCRUDはそのまま残し、GET /users/me だけ追加する。
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/jwt-auth.guard"; // 追加
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersService } from "./users.service";
type AuthenticatedRequest = {
user: {
userId: string;
email: string;
};
};
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// 追加: JWTがないと401になる
@UseGuards(JwtAuthGuard)
@Get("me")
me(@Req() req: AuthenticatedRequest) {
return req.user;
}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Patch(":id")
update(@Param("id") id: string, @Body() dto: UpdateUserDto) {
return this.usersService.update(id, dto);
}
@Delete(":id")
remove(@Param("id") id: string) {
return this.usersService.remove(id);
}
}@Get("me") は @Get(":id") より上に置く。下に置くと、/users/me が id = "me" として扱われる可能性がある。
14. 動作確認
起動する。
pnpm run start:devまずユーザーを作る。
curl -s -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"email":"taro@example.com","name":"Taro","password":"password123"}'ログインしてJWTを取得する。
curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"taro@example.com","password":"password123"}'accessToken が返る。
{
"accessToken": "xxxxx.yyyyy.zzzzz"
}トークンなしで認証必須APIを叩くと401になる。
curl -i http://localhost:3000/users/meレスポンス例。
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8{
"statusCode": 401,
"message": "Unauthorized",
"path": "/users/me",
"timestamp": "2026-05-13T00:00:00.000Z"
}トークンありなら成功する。
curl -s http://localhost:3000/users/me \
-H "Authorization: Bearer <accessToken>"<accessToken> はログインAPIで返った値に置き換える。
レスポンス例。
{
"userId": "cmxxxxxxxxxxxxxxxxxxxxx",
"email": "taro@example.com"
}これは JwtStrategy の validate() で返した値が req.user に入り、GET /users/me でそのまま返っているため。
実装タスク(チェックリスト)
-
schema.prismaにpasswordHashを追加し、migrate/generateする -
.envにJWT_SECRETを追加する -
CreateUserDtoにpasswordを追加する -
UsersService.createでbcrypt.hashし、passwordHashを保存する -
LoginDto/AuthService/AuthControllerを作る -
JwtStrategy/JwtAuthGuard/AuthModuleを作る -
AppModuleにAuthModuleを追加する -
GET /users/meに@UseGuards(JwtAuthGuard)を付ける -
curlでログイン・401・認証成功を確認する
次のステップ
NestJS + Swaggerで Bearer 認証をUIから試せるようにする。