認証を実装する時に困ったこと
はじめに
Webアプリにログイン機能をつけようとしたら登場人物が多すぎて、誰が何をやっていてどんな順でデータが流れているのかまるで分かりませんでした。
bcrypt? JWT? Cookie? Passport? Strategy? Guard? Decorator?
という状態です。この記事では、NestJSとVue3で予約システムの認証機能を作ったときに詰まった疑問を、データの流れを中心にまとめていきます。自分の理解の整理も兼ねています。
実装したファイル
| ファイル | 説明 |
|---|---|
| jwt.strategy.ts | CookieからJWTを抽出・検証 |
| jwt-auth.guard.ts | 認証Guard |
| current-user.decorator.ts | ユーザー情報取得デコレータ |
| auth.module.ts | PassportModule追加、exports設定 |
| reservation.service.ts | userId紐付け対応 |
| reservation.controller.ts | Guard適用、userId取得 |
1. まずHTTPリクエストを理解する
HTTPリクエストの構造
ブラウザからサーバーに送られるリクエストは、大きく3つの部分で構成されています。
| 部分 | 役割 | 認証との関わり |
|---|---|---|
| リクエストライン | どのURLにどのHTTPメソッドでアクセスするか | POST /reservationsのようにエンドポイントを指定 |
| ヘッダー | リクエストのメタ情報 | CookieでJWT(access_token)を送る |
| ボディ | リクエストの本体データ | 予約情報やログイン情報を送る |
困ったこと:ヘッダーとボディの使い分け
最初に困ったのは「データはどこに入れて送るのか」でした。Cookieを送る処理なんて書いていないのに、なぜかサーバー側で受け取れていて???になりました。
- ログイン時:メールアドレスとパスワードはボディに入れて
POSTで送る - ログイン後:JWTはヘッダー(Cookie)に入って自動的に送られる
そう、自動で送られるのです。
なぜボディではなくヘッダーなのかというと、ヘッダーならGETリクエストでも送れるし、Cookieならブラウザが自動送信してくれるからです。ボディはPOSTやPUTでしか使えません。ここはこのあと触れるNestのデコレータで、さらなる謎を呼んで宇宙になった箇所でもあります。
困ったこと:requestオブジェクトとは結局何なのか
後述するjwt.strategy.tsにRequestという型が登場します。これはHTTPリクエスト全体を表すオブジェクトで、中身はこんな感じです。
request = {
url: '/api/reservations',
method: 'POST',
headers: { ... },
cookies: {
access_token: 'eyJhbGciOiJIUzI1...' // ← JWTはここにいる
},
body: {
lastName: '山田',
firstName: '太郎',
...
},
user: { userId: 1, mail: '...' } // ← 認証成功後に追加される
}
ちなみにaccess_tokenというのは私が命名したもので、自由に変えられます。
ヘッダー、ボディ、Cookieを全部ひとまとめにしたのがrequestです。認証の文脈では特にrequest.cookiesとrequest.userが重要になります。
2. bcrypt — パスワードを安全に保存する
さて、ここから本題に入っていきます。
なぜパスワードをそのまま保存してはいけないのか
DBにパスワードを平文で保存すると、DBが漏洩した瞬間に全ユーザーのパスワードがバレます。そこでbcryptを使い、ハッシュ値に変換してから保存します。
"mypassword123"というパスワードをbcrypt.hash()に通すと、"$2b$10$N9qo8uLOickgx2ZMRZoMye..."のような文字列になり、これをDBに保存します。
- 同じパスワードでも毎回違うハッシュ値になる(内部でランダムなソルトを自動生成するため)
- ハッシュ値からパスワードを逆算するのは実質不可能
コード例
import * as bcrypt from "bcrypt";
// 登録時:パスワードをハッシュ化してDBに保存
const hashedPassword = await bcrypt.hash("mypassword123", 10);
// ログイン時:入力されたパスワードとDBのハッシュ値を比較
const isMatch = await bcrypt.compare("mypassword123", hashedPassword);
// → trueならパスワード正解
ここで疑問です。パスワードから毎回違うハッシュ値になるのに、どうやってログイン時にDBに保存されているものと入力されたものが一致するかを検証できるのでしょうか。
compare()はどうやって照合しているのか
答えは、ハッシュ値の中にソルトが埋め込まれているからでした。
$2b$10$N9qo8uLOickgx2ZMRZoMye...という文字列は、$区切りで「bcryptのバージョン」「ソルトラウンド」「ソルト22文字+ハッシュ本体」という構造になっています。
| パーツ | 例 | 説明 |
|---|---|---|
| バージョン | $2b$ | bcryptのアルゴリズム版。2a→2b→2yと改良されている |
| コストファクター | $10$ | ハッシュ計算を2^10 = 1,024回繰り返す。1増やすと時間が2倍 |
| ソルト(22文字) | N9qo8uLOickgx2ZMRZoMye | ハッシュ化時に自動生成されるランダム文字列(Base64) |
| ハッシュ本体(31文字) | IjZAgcfl7p92ldGxad68LJZdL17lhWy | パスワード+ソルトから算出された最終結果 |
つまりcompare()は、保存されたハッシュ値をパースしてバージョン・コスト・ソルトを取り出し、入力パスワードを同じソルト・同じコストで再ハッシュして、結果を比較しています。だから照合できるわけです。
なお最後の比較はタイミングセーフ比較といって、一致でも不一致でも常に同じ時間をかけます。通常の===は不一致を見つけた瞬間に返るため、応答時間の差から情報が漏れる(タイミング攻撃)リスクがあるからです。
ソルトって何? 第2引数の10って何?
これはソルトラウンド(ストレッチング回数)で、ハッシュ化を何回繰り返すかの指標です。数値が大きいほど安全ですが、そのぶん計算に時間がかかります。10は一般的な推奨値で、1回のハッシュ化に約100msかかります。
3. JWT — 「ログイン済み」の証明書
JWTとは
パスワード検証に成功したら、サーバーがJWT(JSON Web Token)を発行します。これは「この人はログイン済みです」とサーバーが署名した証明書のようなものです。
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsIm1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn0.xxxxx
よく見るとピリオドで3つに分けられています。
- ヘッダー:どのアルゴリズムで署名したかの情報
- ペイロード:ユーザーIDなど、含めたい情報を入れる。Base64でデコードすれば誰でも読めるので、パスワードは絶対に入れない
- 署名:サーバーだけが持つ秘密鍵で作成する。改ざんされたら検証で弾ける
コード例
import { JwtService } from "@nestjs/jwt";
// ログイン成功後、JWTを生成(ペイロードにuserIdとmailを含める)
const token = jwtService.sign({ userId: user.id, mail: user.mail });
// リクエスト時、JWTを検証
const payload = jwtService.verify(token);
// → 改ざんされていなければ { userId: 1, mail: "test@example.com" } が返る
JWTの中身は暗号化されていない
JWTのペイロード部分はただのBase64エンコードで、暗号化ではありません。つまり誰でもデコードして中身を読めます。
// ペイロード部分をデコードすると...
atob("eyJ1c2VySWQiOjEsIm1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn0");
// → '{"userId":1,"mail":"test@example.com"}'
ではJWTは何を守っているのかというと、改ざんの検知です。署名があるのでペイロードを書き換えると検証が通らなくなります。だから機密情報は絶対に入れてはいけません。逆に、やり取りしたい情報はここに入れれば別の場所で取り出せます。
4. Cookie — JWTの保管場所
なぜCookieを使うのか
| 保存場所 | 安全性 | 理由 |
|---|---|---|
| localStorage | 低い | JavaScriptで自由に読めてしまう(XSSに弱い) |
| Cookie(HttpOnly) | 高い | JavaScriptからアクセスできない |
今回はCookieに保存しました。
Cookieに設定すべき属性
res.cookie("access_token", jwtToken, {
httpOnly: true, // JSからアクセス不可(XSS対策)
secure: true, // HTTPSでのみ送信
sameSite: "strict", // 別サイトからのリクエストで送信しない(CSRF対策)
maxAge: 24 * 60 * 60 * 1000, // 有効期限: 24時間
});
一度Cookieに保存されれば、以降のリクエストではブラウザが自動的にCookieを送信してくれます。フロントエンド側で特別な処理は不要です。
困ったこと:Cookieが送信されない
withCredentials: trueを設定しないと、クロスオリジン(異なるドメインやポート間)でCookieは送信されません。開発環境ではフロントがlocalhost:5173、バックエンドがlocalhost:3000のようにポートが異なるため、これがないとCookieが飛びません。
// フロント側(apiClient.ts)
const apiClient = axios.create({
baseURL: "http://localhost:3000",
withCredentials: true, // これが必須
});
// バックエンド側(NestJSのmain.ts)
app.enableCors({
origin: "http://localhost:5173",
credentials: true, // これも必須
});
両方設定しないと動きません。片方だけだと「Cookieがセットされない」「Cookieが送られない」という症状になります。
5. Passportモジュールとは何か
ここからNestJS特有の話に入ります。カオスが極まっていた部分です。
Passportとは
PassportはNode.jsの認証ミドルウェアライブラリです。元々はExpress用ですが、NestJSでもラッパーを通して使えます。「認証の仕組みを標準化してくれるフレームワーク」と理解しました。
なぜPassportを使うのか
自分でJWTの検証ロジックを毎回書くのは面倒だし、間違いやすいです。PassportはStrategy(戦略)パターンを使って、認証の方法を差し替え可能にしてくれます。
考え方としては、「認証の方法は色々あるけれど、検証する→成功したらユーザー情報を返す→失敗したらエラー、という流れは全部同じでしょう。だから流れはこちらで管理するので、検証ロジックだけ教えてください」というものだと捉えています。
ここは私がこうだろうと結論づけたものなので、間違っていたら申し訳ないです。
6. Strategyファイルとは何か
Strategyとは
Strategyは「認証のやり方」を定義するクラスです。Passportに対して「JWTをどこから取り出して、どう検証するか」を教えます。
実際のjwt.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Request } from "express";
export interface JwtPayload {
userId: number;
mail: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET environment variable is not set");
}
super({
// JWTをどこから取得するか → Cookieのaccess_tokenから
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
return request?.cookies?.access_token;
},
]),
// 期限切れのJWTを拒否するか
ignoreExpiration: false,
// 秘密鍵(JWTの署名検証に使用)
secretOrKey: secret,
});
}
// JWTの検証が成功した後に "勝手に" 呼ばれる
async validate(payload: JwtPayload) {
return { userId: payload.userId, mail: payload.mail };
}
}
困ったこと:validate()は誰が呼ぶのか
Passportが自動で呼びます。流れとしてはこうです。
- リクエストが来る
- GuardがPassportを起動する
- PassportがStrategyの設定に従ってCookieからJWTを取り出す
- PassportがJWTの署名と有効期限を検証する
- 検証成功でStrategyの
validate()が呼ばれる validate()の戻り値がrequest.userにセットされる
つまりvalidate()は「JWTは正しかった。で、このリクエストにどんなユーザー情報を紐付ける?」という質問に答えるメソッドだと思っています。
困ったこと:validate()の戻り値はどこに行くのか
validate()が返したオブジェクトは、Passportによってrequest.userに自動的にセットされます。
// validate() が { userId: 1, mail: "test@example.com" } を返すと...
// Controller内で
@Get()
findByUser(@Req() req) {
console.log(req.user);
// → { userId: 1, mail: "test@example.com" }
}
これが「JWTのユーザー情報がリクエストに紐付く」瞬間です。
困ったこと:jwtFromRequestのコールバック内のrequest: Requestとは
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
return request?.cookies?.access_token;
},
]),
ここのrequestはHTTPリクエスト全体のオブジェクト(1章で説明したもの)です。Passportが「JWTをどこから取り出すか」を知るために、リクエスト全体を渡してくれています。その中のcookies.access_tokenを返すことで、「CookieからJWTを取り出せ」と指示しているわけです。
7. Guardとは何か — UseGuards, AuthGuard, jwt-auth.guard.ts
Guardの役割
NestJSのGuardは「このリクエストを通していいか?」を判断する門番です。Controllerのメソッドが実行される前に呼ばれ、NGなら401 Unauthorizedを返します。私は濾過装置みたいなイメージで認識しています。
困ったこと:フロントからのPOSTデータは最初にGuardに入る?
違いました。正確な流れはこうです。
- NestJSのルーターが最初にリクエストを受け取る
@Controller('reservations')を見つける@UseGuards(JwtAuthGuard)があるので、Controllerの実行前にGuardを実行- GuardがOKを返して初めてControllerのメソッドが実行される
つまりGuardは「最初」ではなく、ルーティングの後、Controllerの前です。
AuthGuard(‘jwt’)とは
@nestjs/passportが提供するGuardで、「Passportのjwt Strategyを使って認証する」という意味になります。内部では以下をやっています。
- Passportを起動
'jwt'という名前で登録されたStrategy(つまりJwtStrategy)を探す- そのStrategyに従ってJWTを取り出し、検証する
- 成功したら
request.userにユーザー情報をセットして次へ進む - 失敗したら401エラーを返す
この2番と4番を分かっていない状態で進めていたせいで(自覚もなく)意味がわかりませんでした。jwtは文字列だし、勝手に対応するStrategyが呼ばれてるし、reqにはいつの間にかデータがセットされているし、、、、、、、
困ったこと:'jwt'という文字列はどこから来るのか
passport-jwtのStrategyクラスがデフォルトで'jwt'という名前を持っています。PassportStrategy(Strategy)とすると、この名前が自動的に使われます。
名前を変えたい場合は第2引数で指定できます。
// 'jwt-cookie' という名前で登録
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-cookie') {
// 使う側も名前を合わせる
@UseGuards(AuthGuard('jwt-cookie'))
実際のjwt-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
あの、中身が空っぽなのですが
実は空ではなく、AuthGuard('jwt')を継承しているので内部では自動的に以下が実行されます。
'jwt'という名前で登録されたStrategy(つまりJwtStrategy)を探す- JwtStrategyの処理を実行(Cookie取得→JWT検証→
validate()) - 成功なら
request.userにデータをセット、失敗なら401エラー
わざわざクラスを作る理由は2つあるらしいです(ここはAI生成です)。ひとつはAuthGuard('jwt')を毎回書くよりJwtAuthGuardのほうが読みやすいという点、もうひとつは後からエラーメッセージの変更やロギングを追加できるという点です。
// カスタマイズ例:エラーメッセージを変更
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
handleRequest(err, user, info) {
if (err || !user) {
throw new UnauthorizedException("ログインが必要です");
}
return user;
}
}
UseGuardsデコレータ
@UseGuards()は「このGuardを使え」とNestJSに指示するデコレータです。
// Controller全体に適用(実際のコード)
@Controller('reservations')
@UseGuards(JwtAuthGuard) // ← このController内の全メソッドに認証を要求
export class ReservationController { ... }
8. デコレータの挙動を理解する
NestJSはデコレータを多用します。認証周りでよく出てくるものを整理します。
デコレータとは
デコレータは、クラスやメソッドにメタデータを付与する構文で、@から始まります。
@Controller("reservations") // このクラスは /reservations のコントローラーです
@UseGuards(JwtAuthGuard) // このController全体に認証Guardを適用
export class ReservationController {
@Post() // POST /reservations に対応します
async create(
@CurrentUser('userId') userId: number, // JWTからuserIdを取得
@Body() createReservationDto: CreateReservationDto // ボディを取得
) { ... }
}
デコレータ自体は処理をしません。「このメソッドにはこういう属性がありますよ」とNestJSに伝えるだけで、実際の処理はNestJSのランタイムがメタデータを読み取って行います。
認証で使うパラメータデコレータ
| デコレータ | 取得するもの | 使い場面 |
|---|---|---|
@Body() | リクエストボディ | 予約情報やログイン情報 |
@Req() | リクエストオブジェクト全体 | request.userにアクセスしたい時 |
@Res() | レスポンスオブジェクト | Cookieをセットしたい時 |
@CurrentUser() | request.user(カスタム) | 認証済みユーザー情報を取得 |
実際のcurrent-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const CurrentUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return null;
}
return data ? user[data] : user;
});
え、@CurrentUser('userId')で文字列そのまま渡してる!文字列そのまま渡してますよ!気づいて!
// Controller側
@CurrentUser('userId') userId: number
// ↑ この文字列が...
// Decorator側
(data: string | undefined, ctx: ExecutionContext) => {
// ↑ ここに入る! data = 'userId'
'userId'は変数ではなく文字列リテラルです。パラメータデコレータの第1引数dataに自動的に渡されます。NestJSの仕組みとして、@デコレータ名(引数)の引数がそのままdataになります。
そしてreturn data ? user[data] : userで返された値は、そのデコレータが付いている引数、つまり@CurrentUser('userId') userId: numberのuserIdに格納されます。パラメータデコレータの戻り値は、そのまま引数の値になるということです。
9. 全体の流れ — 予約送信からDB保存まで
最後に、入力された情報がどう動くのかを全体で見直します。
ユーザー登録とログイン
ユーザー登録では、パスワードをbcrypt.hash()に通してからDBに保存します。ログインは次の流れです。
- ユーザーがメールアドレスとパスワードを送信する
- サーバーがDBのハッシュ値と
bcrypt.compare()で照合する - 一致したら
jwtService.sign({ userId: user.id, mail: user.mail })でJWTを生成する res.cookie('access_token', jwt, { httpOnly: true, ... })でCookieにセットする
あとはブラウザがCookieを自動保存してくれるので、フロント側のコードは何もしなくて良いです。
予約送信の流れ
- Reservation.vueで「予約を確定する」ボタンをクリックし、
apiClient.post('/reservations', form)を実行する。CookieにJWT、Bodyに予約情報が入ってリクエストが飛ぶ - NestJSのルーターが
/reservationsへのPOSTだと判断して@Controller('reservations')を見つける。@UseGuards(JwtAuthGuard)があるので、まずGuardを実行する - JwtAuthGuardがJwtStrategyを呼び出す
- JwtStrategyが
request.cookies.access_tokenを取得し、secretOrKeyで署名を検証、有効期限をチェックしてvalidate()を実行する。戻り値の{ userId: 1, mail: "test@example.com" }がrequest.userにセットされる - ReservationControllerが
@CurrentUser('userId')でrequest.userからuserIdを、@Body()でリクエストBodyを受け取り、reservationService.create(dto, userId)を呼ぶ - ReservationServiceがPrismaで
reserveDetail.create()を実行する - MySQLに
userId=1付きでINSERTされる - レスポンスが返り、フロントで
router.push('/completeReservation')する
ファイル呼び出し順まとめ
| 順番 | ファイル | 役割 |
|---|---|---|
| 1 | Reservation.vue | ユーザー操作、API呼び出し |
| 2 | apiClient.ts | axiosでHTTPリクエスト送信 |
| 3 | NestJSルーター | @Controllerを見つける |
| 4 | jwt-auth.guard.ts | 認証処理を起動 |
| 5 | jwt.strategy.ts | CookieからJWT取得・検証 |
| 6 | current-user.decorator.ts | request.userから値を抽出 |
| 7 | reservation.controller.ts | リクエスト受付、Serviceに委譲 |
| 8 | reservation.service.ts | Prismaでデータベース操作 |
実際のコード(Controller)
@Controller("reservations")
@UseGuards(JwtAuthGuard)
export class ReservationController {
constructor(private readonly reservationService: ReservationService) {}
@Post()
async create(@CurrentUser("userId") userId: number, @Body() createReservationDto: CreateReservationDto) {
return this.reservationService.create(createReservationDto, userId);
}
@Get()
async findByUser(@CurrentUser("userId") userId: number) {
return this.reservationService.findByUserId(userId);
}
}
実際のコード(Service)
@Injectable()
export class ReservationService {
constructor(private prisma: PrismaService) {}
async create(createReservationDto: CreateReservationDto, userId: number) {
return this.prisma.reserveDetail.create({
data: {
~
userId: userId, // ← JWTから取得したuserIdがここでDBに保存される
},
});
}
async findByUserId(userId: number) {
return this.prisma.reserveDetail.findUnique({
where: { userId },
});
}
}
10. ログイン後のリクエスト — ユーザー情報の紐付け
「ログインしたユーザーの情報を取得する」とは
これが初心者にとって最大の謎でした。ログイン後に「自分の」予約を取得するとき、フロントはユーザーIDを明示的に送っていないのに、サーバーは誰のデータを返せばいいか分かっています。なぜでしょうか。
答えは、JWTの中にユーザーIDが入っているからです。フロントはGET /reservationsにCookieを添えて送るだけで、サーバー側はCookieからJWTを取り出し、検証し、ペイロードからuserIdを取り出してDBを検索しています。
具体例:自分の予約を取得する
@Get()
async findByUser(@CurrentUser('userId') userId: number) {
// userIdはJWTから自動的に取り出されたもの
return this.reservationService.findByUserId(userId);
}
ユーザーIDをリクエストに含める必要はありません。JWTが「この人はuserId: 1です」と教えてくれます。
困ったこと:「紐付け」はDBのリレーションの話ではない
最初、「ユーザーに紐づいた情報の取得」と聞いて、DBのテーブル結合のことだと思っていました。実際には、認証の文脈での「紐付け」は2段階あります。
- JWTとユーザーの紐付け:JWTのペイロード内の
userIdをvalidate()で取り出し、request.userにセットする - ユーザーとデータの紐付け:
request.userのuserIdを使ってDBからデータを取得する(これは普通のDBクエリ)
JWT { userId: 1 }からrequest.userへ、@CurrentUser('userId')でuserId = 1を取り出して、最後にprisma.reserveDetail.findUnique({ where: { userId: 1 } })にたどり着く、という流れです。第1段階はPassportとStrategyが自動でやってくれて、第2段階は自分でServiceに書きます。
11. まとめ
登場人物とそれぞれの役割を並べると、こうなります。
| 要素 | 役割 |
|---|---|
| bcrypt | パスワードを元に戻せない形にして保存する |
| JWT | ログイン成功の証明書を発行する |
| Cookie | 証明書をブラウザに安全に保管する(access_token) |
| Passport | 認証の流れを管理するフレームワーク |
| Strategy | JWTをどこから取り、どう検証するかを定義する |
| Guard | Controllerの前で認証チェックをする門番 |
| Decorator | NestJSにメタデータを伝える構文 |
データの流れとしては、登録時はパスワードをハッシュ化してDBに保存、ログイン時はbcrypt.compare()で照合してJWTを発行しCookieにセット、認証済みリクエストではCookieからGuard、Strategy(検証とvalidate())を経てrequest.userに入り、@CurrentUser('userId')でControllerに渡ってServiceからDBへ、という順になります。
こうして書き出してみると、結局は「誰が自動でやってくれているのか」を把握できていなかっただけでした。同じところで迷っている人の役に立てば嬉しいです。