awesome-hacks
Docs

NestJS Guard / JWT認証

loginでJWTを発行し、JwtAuthGuardで保護エンドポイントを作る(Passport JWTパターン)

最終更新:2026/05/13

NestJSの例外処理まで終えている前提。
この記事では、既存のユーザー管理APIにログイン機能を追加し、JWTがないと見られないAPIを作る。

この記事でやること

  • UserpasswordHash を追加する
  • POST /auth/loginJWTを発行する
  • JwtStrategyJwtAuthGuard で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/bcrypt

npmの場合。

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt

2. schema.prismapasswordHash を追加する

編集するファイルは 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 -v

Prisma 7はNode.js 20.19.0 以上が前提。v20.15.1 のような古い20系だと、@prisma/devERR_REQUIRE_ESM で失敗することがある。先にNode.js 22系、または20.19以上へ上げてから依存関係を入れ直す。

# nvmを使っている場合の例
nvm install 22
nvm use 22

node -v
pnpm install
pnpm rebuild better-sqlite3

Node.jsが 20.19.0 以上になってから、次を実行する。

pnpm exec prisma migrate reset
pnpm exec prisma migrate dev --name add_password_hash
pnpm exec prisma generate

migrate reset はDB内のデータを削除する。今回のような学習用サンプルなら問題になりにくいが、残したいデータがある場合は使わない。

npmの場合は次。

npx prisma migrate reset
npx prisma migrate dev --name add_password_hash
npx prisma generate

B. 既存データを残したい場合

既存データを残したい場合は、いきなり必須カラムにしない。たとえば次のように一度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. CreateUserDtopassword を追加する

編集するファイルは 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.hashpasswordHash を作る。

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の形を表す。
ユーザー作成用の CreateUserDtoemail / 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つ。

  1. email からユーザーを探す
  2. bcrypt.compare で入力パスワードと passwordHash を照合する
  3. 認証できたら 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を作っただけではアプリに登録されない。AuthControllerAuthServiceJwtStrategyJwtModulePassportModule をこのModuleにまとめることで、認証機能として動くようにする。

ここで JwtModule.register() しているのは、AuthServiceJwtService がJWTを発行できるようにするため。PassportModuleJwtStrategy は、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. AppModuleAuthModule を追加する

編集するファイルは 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 {}

AuthModuleimports に入れないと、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/meid = "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"
}

これは JwtStrategyvalidate() で返した値が req.user に入り、GET /users/me でそのまま返っているため。

実装タスク(チェックリスト)

  • schema.prismapasswordHash を追加し、migrate/generateする
  • .envJWT_SECRET を追加する
  • CreateUserDtopassword を追加する
  • UsersService.createbcrypt.hash し、passwordHash を保存する
  • LoginDto / AuthService / AuthController を作る
  • JwtStrategy / JwtAuthGuard / AuthModule を作る
  • AppModuleAuthModule を追加する
  • GET /users/me@UseGuards(JwtAuthGuard) を付ける
  • curl でログイン・401・認証成功を確認する

次のステップ

NestJS + SwaggerBearer 認証をUIから試せるようにする。

参考