🔐

【第1回】NestJS(GraphQL)×Next.jsでJWT認証を実装する - NestJSでの認証基盤構築(全3回)

に公開

本記事のサマリ

本記事は、NestJSとNext.jsを組み合わせたモダンなWeb開発における認証・認可実装の3回シリーズの第1回です。今回はバックエンド(NestJS)でのJWT認証実装に焦点を当て、Passportを使った認証戦略の構築から、GraphQL環境での認証統合まで実際に手を動かして分かったことを共有します。第2回ではロールベースアクセス制御、第3回ではフロントエンドでの認証状態管理を扱う予定です。

今回のコードは下記に公開しています。
https://github.com/toto-inu/202511-ts-auth

この技術選択に至った経緯

最近の案件でGraphQLを使う機会が増えてきて、RESTと比較した時の型安全性の高さやフロントエンドとの連携のスムーズさは確かに感じています。ただ、認証周りの実装となると毎回少し悩ましい部分があります。特にJWTトークンをどう扱うか、バックエンドでの認証処理をどう組み立てるかは、プロジェクトごとに微妙に違う判断をしている気がします。

今回はバックエンドにNestJSを選択しました。NestJSを選んだのは、TypeScriptがネイティブで使えることと、Angularライクな依存性注入の仕組みが大規模開発でも破綻しにくく、個人的にも好んで使っているためです。GraphQLとの統合も公式でサポートされていて、Code Firstアプローチでスキーマと型定義を一元管理できる点も魅力的です✨

JWT認証の基本フロー

まず、このアプリケーションでの認証がどう流れるかを整理しておきます。今回はバックエンド側の処理に焦点を当てているため、サーバー側で行われる認証処理を中心に見ていきます。

ユーザーがログインリクエストを送信すると、GraphQL Mutationを通じてバックエンドにリクエストが送られます。バックエンドでは、入力されたパスワードをbcryptで検証し、認証が成功すればJWTトークンを生成して返します。以降のリクエストでは、クライアントから送られてくる Authorization: Bearer <token> ヘッダーをJwtStrategyが検証し、ユーザー情報をリクエストに追加して後続の処理で利用できるようにします。

このフローを実現するために複数のパッケージと仕組みを組み合わせていますが、それぞれの役割を理解しておくと実装時の見通しが良くなります👍

バックエンドでのJWT実装

使用パッケージの役割分担

バックエンドで使った主要なパッケージとその役割は以下の通りです。

パッケージ 役割
@nestjs/jwt JWTの生成と検証を担当。NestJSの依存性注入システムと統合されており、JwtService を通じて簡単にトークンを扱える
@nestjs/passport Passportの認証戦略をNestJSで使えるようにするラッパー。複数の認証方式を統一したインターフェースで扱える
passport 認証戦略を抽象化するライブラリ。JWT以外にもOAuthやローカル認証など様々な認証方式に対応
passport-jwt JWTトークンの検証戦略を提供。ExtractJwt.fromAuthHeaderAsBearerToken() でAuthorizationヘッダーからトークンを取り出す処理が簡潔に書ける
bcrypt パスワードのハッシュ化ライブラリ。平文パスワードをデータベースに保存しないための必須ツール

NestJSの公式ドキュメントが参考になります。

https://docs.nestjs.com/security/authentication

Passport の役割を理解する

ここで少し passport パッケージについて補足しておきます。表では簡単に説明しましたが、実際の実装を理解する上で重要な部分なので、もう少し丁寧に見ていきましょう。

Passportは、Node.jsの認証ミドルウェアとして広く使われているライブラリです。認証方式(Strategy)を「戦略パターン」で抽象化しているのが特徴で、JWT認証、OAuth、ローカル認証(ユーザー名とパスワード)など、500種類以上の認証方式に対応しています。

https://www.passportjs.org/

なぜPassportを使うのかというと、認証方式が変わっても基本的なコードの構造を変えずに済むからです。例えば、最初はローカル認証で実装していたけど、後からGoogle OAuthを追加したい、といった場合でも、新しい戦略を追加するだけで対応できます。認証のロジック自体は各戦略にカプセル化されているため、アプリケーション側のコードはほとんど変更する必要がありません。

今回使っている passport-jwt は、Passportの「JWT戦略」を実装したパッケージです。これを使うことで、Authorizationヘッダーからトークンを取り出す処理や、秘密鍵を使った署名検証などを、標準的な方法で実装できます。

@nestjs/passport は、このPassportをNestJSで使いやすくするラッパーです。NestJSの依存性注入システムと統合されているため、PassportStrategy クラスを継承するだけで、認証戦略をNestJSのモジュールシステムに組み込めます。これにより、Guards や Decorators と組み合わせた宣言的な認証制御が可能になります。

NestJSでのPassport統合についての詳細はこちらを参照してください。

https://docs.nestjs.com/recipes/passport

つまり、passport(本体)→ passport-jwt(JWT戦略の実装)→ @nestjs/passport(NestJS統合)という3層構造になっていて、それぞれが明確な役割を持っているわけです。

JwtStrategy の実装

NestJSでは、Passportの戦略をカスタマイズして認証処理を実装します。今回は JwtStrategy を作成して、トークンのペイロードからユーザー情報を復元する処理を書きました。

// api/src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('JWT_SECRET') || 'default-secret',
    });
  }

  async validate(payload: any) {
    const user = await this.authService.validateUser(payload.sub);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

ポイントは validate メソッドで、JWTのペイロードに含まれる sub(ユーザーID)を使ってデータベースからユーザー情報を取得しています。これにより、後続のResolverで @CurrentUser() デコレーターを通じてユーザー情報を取得できるようになります!

JWTペイロードの中身を理解する

ここで、JWTトークンに実際に何が含まれているのかを整理しておきます。JWTは3つの部分(ヘッダー、ペイロード、署名)で構成されていますが、validate メソッドに渡されるのは「ペイロード」部分です。

今回のアプリケーションでは、以下のような情報をペイロードに含めています。

フィールド名 説明 値の例 設定箇所
sub Subject(主体)の略。JWT標準のクレームで、通常はユーザーIDを格納する 123 AuthService でトークン生成時に設定
email ユーザーのメールアドレス user@example.com AuthService でトークン生成時に設定
role ユーザーのロール。ロールベースアクセス制御に使用 USER または ADMIN AuthService でトークン生成時に設定
iat Issued At(発行日時)の略。JWT標準のクレームで、トークンが発行された時刻(UNIX時間) 1704067200 JwtService が自動付与
exp Expiration(有効期限)の略。JWT標準のクレームで、トークンの有効期限(UNIX時間) 1704153600 JwtService が自動付与

JWT標準クレームとカスタムクレームの違い

  • 標準クレームsub, iat, exp など): JWT仕様で定義されている予約済みのフィールド。多くのライブラリが自動的に処理してくれる
  • カスタムクレームemail, role など): アプリケーション固有の情報を格納するために独自に追加したフィールド

sub を使う理由は、JWT標準に従うことで他のライブラリやツールとの互換性が保たれるからです。例えば、Auth0やFirebase Authenticationなどの認証サービスも sub にユーザーIDを格納します。

実際のトークン生成部分を見ると、このような形になっています。

const accessToken = this.jwtService.sign({
  sub: user.id,        // ← JWT標準クレーム:ユーザーID
  email: user.email,   // ← カスタムクレーム:メールアドレス
  role: user.role,     // ← カスタムクレーム:ロール
  // iat と exp は JwtService が自動的に追加
});

そして、validate メソッドではこのペイロードが引数として渡されます。

async validate(payload: any) {
  // payload の中身:
  // {
  //   sub: 123,
  //   email: 'user@example.com',
  //   role: 'USER',
  //   iat: 1704067200,
  //   exp: 1704153600
  // }
  
  const user = await this.authService.validateUser(payload.sub);
  // ...
}

ペイロードには必要な情報をすべて含めることもできますが、トークンサイズが大きくなるとネットワーク転送量が増えるため、最小限の情報にとどめるのが一般的です。今回は sub をキーにしてデータベースから最新のユーザー情報を取得することで、トークン発行後にロールが変更された場合でも正しく反映されるようにしています。

JWT認証の処理フローを理解する

ここで、JwtStrategyがNestJS上でどのように呼び出され、認証が行われるのかを整理しておきましょう。実際の処理の流れを追うと理解しやすくなります。

1. クライアントからのリクエスト

フロントエンドからGraphQLリクエストが送信される際、Authorizationヘッダーが付与されます。

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

2. Guardによる認証チェックの開始

Resolverに @UseGuards(JwtAuthGuard) が設定されていると、メソッド実行前にGuardが起動します。

@Query(() => User)
@UseGuards(JwtAuthGuard)  // ← ここでGuardが起動
async me(@CurrentUser() user: User) {
  return user;
}

3. JwtAuthGuardがPassportを呼び出す

JwtAuthGuard は passportのクラスである AuthGuard('jwt') を継承しているため、内部でPassportの認証処理が開始されます。この 'jwt' という文字列が、使用する戦略(Strategy)を指定しています。

4. JwtStrategyのコンストラクタ設定が適用される

Passportは登録されている JwtStrategy を見つけ、コンストラクタで設定した内容に従って処理を進めます。

super({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),  // ← Authorizationヘッダーからトークンを抽出
  secretOrKey: configService.get('JWT_SECRET'),              // ← 署名検証用の秘密鍵
});

ここで、ExtractJwt.fromAuthHeaderAsBearerToken() がAuthorizationヘッダーから Bearer に続くトークン部分を取り出します。

5. トークンの署名検証

Passportが自動的にJWTトークンの署名を検証します。secretOrKey で指定した秘密鍵を使って、トークンが改ざんされていないか、有効期限が切れていないかをチェックします。この検証に失敗すると、UnauthorizedException が投げられてリクエストは拒否されます。

6. validateメソッドの呼び出し

署名検証が成功すると、Passportが自動的に validate メソッドを呼び出します。このとき、トークンのペイロード(JWTに含まれるデータ)が引数として渡されます。

async validate(payload: any) {
  // payload = { sub: 123, email: 'user@example.com', role: 'USER', iat: ..., exp: ... }
  const user = await this.authService.validateUser(payload.sub);
  if (!user) {
    throw new UnauthorizedException();
  }
  return user;  // ← この戻り値がrequest.userに設定される
}

ここでは、ペイロードに含まれるユーザーID(sub)を使ってデータベースから実際のユーザー情報を取得しています。トークン自体は有効でも、ユーザーがデータベースから削除されている可能性があるため、この確認は重要です。

7. ユーザー情報のリクエストへの追加

validate メソッドが返したユーザーオブジェクトは、NestJSによって自動的に request.user に設定されます。これにより、後続の処理でユーザー情報にアクセスできるようになります。

8. Resolverメソッドの実行

すべての認証チェックが完了すると、ようやくResolverメソッドが実行されます。@CurrentUser() デコレーターは request.user からユーザー情報を取り出すだけのシンプルな実装です。

@Query(() => User)
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: User) {  // ← request.userがここに渡される
  return user;
}

処理フローのまとめ

リクエスト受信
  ↓
@UseGuards(JwtAuthGuard) が起動
  ↓
JwtAuthGuard → Passport → JwtStrategy へ処理を委譲
  ↓
ExtractJwt.fromAuthHeaderAsBearerToken() でトークン抽出
  ↓
secretOrKey で署名検証(自動)
  ↓
validate(payload) メソッドが呼ばれる
  ↓
ユーザー情報をDBから取得して返す
  ↓
戻り値が request.user に設定される
  ↓
Resolverメソッドが実行される
  ↓
@CurrentUser() で request.user を取得

この一連の流れが理解できると、各パッケージがどのタイミングで何をしているのかが見えてきます。特に、「トークンの抽出」「署名検証」「ユーザー情報の取得」という3つのステップが、それぞれ別のレイヤーで処理されていることがポイントです。

AuthService でのトークン生成

実際のログインやサインアップ処理では、JwtService を使ってトークンを生成します。

// api/src/auth/auth.service.ts (抜粋)
async signup(signupInput: SignupInput): Promise<AuthResponse> {
  const hashedPassword = await bcrypt.hash(signupInput.password, 10);

  const user = await this.prisma.user.create({
    data: {
      email: signupInput.email,
      password: hashedPassword,
    },
  });

  const accessToken = this.jwtService.sign({
    sub: user.id,
    email: user.email,
    role: user.role,
  });

  return { accessToken, user: user as any };
}

パスワードのハッシュ化には bcrypt.hash() を使い、ソルトラウンド数は10を指定しています。これは公式の推奨値です。トークンのペイロードにはユーザーIDとメールアドレス、ロールを含めました。ペイロードに含める情報は必要最小限にとどめるのが一般的ですが、今回は第2回で扱うロールベースアクセス制御のためにロール情報も含めています。

bcryptの詳細はこちらを参照してみてください。

https://github.com/kelektiv/node.bcrypt.js

GraphQL Resolver での認証統合

Resolverでは、@UseGuards() デコレーターを使ってGuardを適用します。

// api/src/auth/auth.resolver.ts (抜粋)
@Query(() => User)
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: User) {
  return user;
}

@CurrentUser() デコレーターは、JwtStrategyで検証されたユーザー情報を取得するカスタムデコレーターです。

// api/src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

GraphQLのコンテキストからユーザー情報を取り出すシンプルな実装ですが、これによりResolverの記述がかなり簡潔になります。

GraphQL特有の部分として、GqlExecutionContext を経由してHTTPリクエストを取得していることにも注目してください。REST APIとは異なり、GraphQLではすべてのリクエストが同じエンドポイント(/graphql)に送られるため、このような処理が必要になります。

実装を通じて学んだこと

今回のNestJSでのJWT認証実装を通じて、いくつか気づいたことがあります。

NestJSのエコシステムは、Passportを中心に認証周りのライブラリがよく整備されています。@nestjs/jwt@nestjs/passportpassport-jwt といったパッケージがそれぞれ明確な役割を持っていて、組み合わせることでJWT認証を実現できます。公式ドキュメントも充実していたため、迷う場面はそれほど多くありませんでした!

Passportの戦略パターンは非常によく設計されていると感じました。認証ロジックが戦略クラスにカプセル化されているため、テストも書きやすく、後から別の認証方式を追加する際の拡張性も高いです。

GraphQLとの統合では、REST APIとは異なりすべてのリクエストが同じエンドポイント(/graphql)に送られるため、GuardやDecoratorsで GqlExecutionContext を経由する必要があります。これを忘れるとGuardが正しく動作しないので、注意が必要です。

JWTのペイロード設計は、アプリケーションの要件とセキュリティのバランスを考える必要があります。今回はロール情報をペイロードに含めましたが、これにより第2回で扱うロールベースアクセス制御がスムーズに実装できます。一方で、トークンサイズとセキュリティリスクのトレードオフも考慮すべき点です。

最後に

今回は、NestJSでのJWT認証実装にフォーカスして、Passportエコシステムの活用方法やGraphQL環境での認証統合について解説しました。特に、JwtStrategyの8ステップの処理フローを理解することで、各パッケージがどのタイミングで何をしているのかが見えてくると思います。

次回(第2回)は、今回構築した認証基盤を使って、ロールベースアクセス制御(RBAC)を実装していきます。RolesGuardを使った認可制御や、認証と認可の責務分離について詳しく解説する予定です。

同じような構成で開発を進める方にとって、何かしらの参考になれば嬉しいです。認証周りは案件ごとに要件が変わりやすい部分でもあるので、今回の実装例をベースにしながら、それぞれのプロジェクトに合わせて調整していけばと思います!👍

株式会社StellarCreate | Tech blog📚

Discussion