ログイン機能、JWTで作って大事故。Web認証はセッション・bcrypt・Cookieで固める
Web認証の実装で迷う人へ。セッションとJWTの使い分け、bcryptでのパスワード保存、Cookie属性、CSRF・セッション固定対策を、コピペで動くコードと失敗談つきで整理しました。
初めて作ったログイン機能を、僕はJWTだけで組みました。
ログインしたらトークンを発行して、ブラウザのlocalStorageに保存。次からはそれを送れば本人と分かる。シンプルで、動いて、満足していました。問題に気づいたのは、退会したユーザーが退会後もログインできると報告が来たときです。
JWTは「一度発行したら、期限が切れるまで有効」な仕組みでした。サーバー側に「この人はもうログアウト」という記録がないので、止められない。慌ててブラックリストをDBに作って、結局セッションと同じことを後付けしました。最初から方式を選び間違えていたわけです。
Web認証は、ログイン画面を作って終わりではありません。パスワードをどう保存するか、ログイン状態をどう持つか、Cookieにどんな鍵をかけるか、別サイトからの攻撃をどう弾くか。ここを曖昧にすると、画面は動いても危ない設計が残ります。今日は、僕が事故って学んだ順番で、Web認証の土台を整理します。
この記事の要点
- ブラウザの通常ログインはサーバー側セッション +
HttpOnlyCookieが扱いやすい。JWTはモバイルや外部API向けの短命トークンに限定する。 - パスワードはbcrypt(ワークファクター10以上)かArgon2idでハッシュ化。平文もSHA-256単発もNG。bcryptは72バイトまでしか効かない点に注意。
- Cookieには
HttpOnly/Secure/SameSite/Path/Max-Ageを必ず明示。本番は__Host-プレフィックスが固い。 - 状態を変えるAPIはOriginチェック + CSRFトークンで守る。ログイン成功時はセッションIDを作り直してセッション固定を防ぐ。
- 「認証(誰か)」と「認可(何をしてよいか)」は別物。ログイン済み=何でもOK、にしない。
認証と認可は、別の仕事
最初にここを混ぜると後で全部こんがらがるので、言葉を分けます。
**認証(Authentication)**は「あなたは誰ですか」を確かめること。メールとパスワードが合っているか、Googleアカウントの本人か、を判定します。**認可(Authorization)**は「あなたはこの操作をしてよいか」を確かめること。ログインしている人でも、他人の請求書は見られない——これは認可の話です。
空港で例えると、パスポートで本人確認するのが認証、搭乗券で「あなたはこの便のこの席」と通すのが認可です。両方ないと搭乗できません。
事故の多くは、この区別が抜けて起きます。「ログインさえしていれば管理画面に入れる」は、認証だけ見て認可を忘れた状態です。後半のRBACの話につながるので、頭の隅に置いておいてください。
セッションとJWT、どっちで作るか
冒頭の僕の失敗は、ここの選択ミスでした。両者の性格はけっこう違います。
| 方式 | 向いている用途 | 強み | つらい点 |
|---|---|---|---|
| サーバー側セッション | 管理画面、SaaS、会員サイト | 失効・強制ログアウト・監査が楽 | RedisやDBなど保存先が要る |
| JWT | モバイルAPI、サービス間呼び出し、外部API | DBを見ずに署名検証だけで通せる | 失効が苦手。長命JWTをブラウザに置くと危険 |
| OAuth / OIDC | Google・GitHub・社内IdPログイン | 自社でパスワードを持たずに済む | あくまでログイン入口。自社の状態管理は別途必要 |
ざっくりした指針はこうです。人間がブラウザで使う通常ログインなら、サーバー側セッション。ログイン状態をサーバーが持っているので、退会・強制ログアウト・「全端末からサインアウト」が一発でできます。僕が後付けで作ったブラックリストは、最初からこれを選んでいれば不要でした。
JWTが向くのは、失効をあまり気にしない短命の用途です。モバイルアプリのアクセストークン、マイクロサービス間の呼び出し、外部に公開するAPI。ここでは「DB参照なしで検証できる」軽さが効きます。JWTを使う場合もアクセストークンは短命にして、更新が必要ならリフレッシュトークンをDB側にハッシュ保存し、ローテーションと盗難検知を設計します。長い有効期限のJWTをlocalStorageに置くのは、僕がやらかした典型的な地雷です。XSS(悪意あるJavaScriptがページ内で動く攻撃)で盗まれた瞬間、サーバー側で止める手段がありません。
迷ったら、まずセッションで作る。これが安全側の初手です。
パスワードはbcryptかArgon2idで潰す
平文保存は論外として、もうひとつやりがちなのがSHA-256一発でのハッシュ化です。SHA-256は速すぎて、GPUで総当たりされると弱い。パスワード保存には、わざと遅く作られた専用アルゴリズムを使います。
OWASP Password Storage Cheat Sheetは、第一候補にArgon2idを挙げています。推奨設定はメモリ19MiB・反復2回・並列度1から。ネイティブ依存を入れられる環境ならargon2や@node-rs/argon2を検討してください。レガシー環境や手軽さ優先なら**bcrypt(ワークファクター10以上)**でも実用十分です。
ひとつ罠があります。bcryptは入力の72バイトまでしか見ません。それ以降の文字は無視されます。長いパスフレーズやマルチバイト文字を許す仕様なら、事前に長さを検証しておくか、Argon2idに寄せる判断をします。
// lib/auth/password.ts — bcryptでハッシュ化と検証
import bcrypt from "bcryptjs";
import { z } from "zod";
// 12〜128文字に制限(bcryptの72バイト制限も意識して上限を置く)
export const passwordSchema = z.string().min(12).max(128);
export async function hashPassword(password: string) {
const parsed = passwordSchema.parse(password);
// 第2引数のコストが大きいほど計算が遅く=総当たりに強い
return bcrypt.hash(parsed, 12);
}
export async function verifyPassword(password: string, hash: string) {
// ハッシュ同士を直接比べず、専用関数でタイミング攻撃を避ける
return bcrypt.compare(password, hash);
}
ログイン失敗時のメッセージも要注意です。「メールアドレスが存在しません」と返すと、攻撃者にアカウントの有無を教えてしまいます。**存在しても間違えても、同じ「認証に失敗しました」**で揃えるのが鉄則です。
Cookieにかける5つの鍵
セッションIDをブラウザに渡すとき、Cookieの属性で守りの強さが決まります。ここは丸暗記でいいくらい毎回同じです。
HttpOnly: JavaScriptからCookieを読めなくする。XSSでセッションIDを盗まれにくくなる。Secure: HTTPSのときだけ送る。通信の盗み見を防ぐ。SameSite: 別サイト経由のリクエストにCookieを付けるかを制御。LaxかStrictで、CSRFの土台を崩す。Path: Cookieを送る範囲。基本は/。Max-Age: 有効期限。だらだら長くしない。
本番のセッションCookieには__Host-プレフィックスが固いです。これを付けると、ブラウザがSecure・Path=/・Domainなしを強制してくれます。下のコードは、開発と本番でCookie名を切り替えています。
// lib/auth/session.ts — サーバー側セッションの最小実装
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
// 環境変数を起動時に検証(秘密鍵が短いと事故るので32文字以上を必須に)
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
export type Role = "user" | "admin";
type SessionRecord = {
userId: string;
role: Role;
csrfToken: string;
expiresAt: number;
};
// デモはメモリ保存。本番はRedis/PostgreSQLなど再起動に耐える保存先へ
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; // 8時間
// 本番は __Host- プレフィックスでSecure/Path=/を強制
export const SESSION_COOKIE_NAME =
env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
function signSessionId(sessionId: string) {
return createHmac("sha256", env.SESSION_SECRET).update(sessionId).digest("base64url");
}
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
// ログイン成功のたびに新しいセッションIDを発行 = セッション固定攻撃を防ぐ
export function createSession(userId: string, role: Role = "user") {
const sessionId = randomBytes(32).toString("base64url"); // 推測困難な乱数
const token = `${sessionId}.${signSessionId(sessionId)}`;
const csrfToken = randomBytes(32).toString("base64url");
sessions.set(sessionId, {
userId,
role,
csrfToken,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
return { token, csrfToken };
}
export function getSession(token?: string) {
if (!token) return null;
const [sessionId, signature] = token.split(".");
if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId); // 期限切れは掃除する
return null;
}
return { id: sessionId, ...session };
}
export function destroySession(token?: string) {
const sessionId = token?.split(".")[0];
if (sessionId) sessions.delete(sessionId);
}
// 別サイトからのPOSTを弾く2段構え(Origin + CSRFトークン)
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (!origin) return;
if (origin !== new URL(request.url).origin) throw new Error("Bad origin");
}
export function assertCsrf(request: Request, session: { csrfToken: string }) {
const submitted = request.headers.get("x-csrf-token");
if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}
ここでのキモは2つ。セッションIDはrandomBytes(32)で推測困難にしていること。そしてログインのたびにcreateSessionで新しいIDを発行していることです。これが地味に大事で、ログイン前から付いていたセッションIDをそのまま使い回すと「セッション固定攻撃」が刺さります。OWASP Session Management Cheat Sheetも、ログインなど権限が変わる瞬間にIDを再生成せよと明記しています。
ログインAPIとCSRF対策をつなぐ
役者がそろったので、ログインのRoute Handlerでつなぎます(Next.js App Router)。やっていることは「入力をZodで検証 → ユーザー検索 → パスワード照合 → セッション発行 → Cookieセット」の一本道です。
// app/api/login/route.ts — ログインの入口
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { SESSION_COOKIE_NAME, createSession, sessionCookieOptions } from "@/lib/auth/session";
export const runtime = "nodejs";
// テストでも同じ入力契約を使えるようexportしておく
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(12).max(128),
});
// デモ用のユーザー検索。実務ではDBからpasswordHashとroleを引く
async function findUserByEmail(email: string) {
if (email !== "[email protected]") return null;
return {
id: "user_123",
role: "admin" as const,
passwordHash: await hashPassword("correct-horse-battery-staple"),
};
}
export async function POST(request: NextRequest) {
const parsed = loginInputSchema.safeParse(await request.json());
// 入力不正もログイン失敗も同じ401で返し、情報を漏らさない
if (!parsed.success) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const user = await findUserByEmail(parsed.data.email);
const passwordOk = user ? await verifyPassword(parsed.data.password, user.passwordHash) : false;
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createSession(user.id, user.role);
const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
// セッションIDはHttpOnlyで隠す
response.cookies.set({ name: SESSION_COOKIE_NAME, value: session.token, ...sessionCookieOptions });
// CSRFトークンはJSから読める別Cookieに(double submit cookie方式)
response.cookies.set({
name: "csrf-token",
value: session.csrfToken,
secure: sessionCookieOptions.secure,
sameSite: "lax",
path: "/",
maxAge: sessionCookieOptions.maxAge,
});
return response;
}
CSRF対策はOriginチェックとトークンの二段構えにしています。CSRF(Cross-Site Request Forgery)は、ログイン済みのブラウザに別サイトから勝手にPOSTさせる攻撃です。ブラウザはCookieを自動で付けてしまうので、「正規サイトから来たリクエストか」を別途確かめる必要があります。状態を変える管理APIでは、セッション・ロール・Origin・CSRFをまとめて見ます。
// app/api/admin/settings/route.ts — 状態変更APIは全部入りで守る
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { SESSION_COOKIE_NAME, assertCsrf, assertSameOrigin, getSession } from "@/lib/auth/session";
const settingsSchema = z.object({ displayName: z.string().min(1).max(80) });
export async function POST(request: NextRequest) {
const session = getSession(request.cookies.get(SESSION_COOKIE_NAME)?.value);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); // 認証
if (session.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); // 認可
try {
assertSameOrigin(request);
assertCsrf(request, session);
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = settingsSchema.safeParse(await request.json());
if (!body.success) return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
// 監査ログ:誰が・いつ・何を・どの結果で(秘密情報は絶対に出さない)
console.info("audit.auth.settings_update", {
actorId: session.userId,
action: "settings.update",
result: "success",
at: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}
middleware.tsでの入口制御は、あくまでUX用の見せ方です。Cookieがあるだけで管理者扱いしてはいけません。本物の判定は、上のようにRoute HandlerやServer Action側でgetSession・CSRF・ロールを再確認します。ここが認証と認可を分ける現場です。
// middleware.ts — 未ログインなら/loginへ逃がすだけ(UX用)
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE_NAME =
process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
const { pathname } = request.nextUrl;
if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
Cookieの細かい属性設計はNext.js認証でのCookie管理、入力検証の型付けはZodの入力バリデーションで別途まとめています。あわせてMDN: Secure cookie configurationも原典として確認してください。
OAuthは自前で作らない
「Googleでログイン」「GitHubでログイン」を実装したくなったとき、僕からの一番のアドバイスは自前で書かないことです。OAuth/OIDCのフローは、stateパラメータ、PKCE、リダイレクトURIの検証など、間違えると穴になる箇所が多すぎます。
Auth.jsのような実績あるライブラリを使い、プロバイダから返ってきたユーザーを自社DBのユーザーへ紐付ける。その後の自社内のログイン状態は、この記事のセッションCookieで持つのが筋のいい形です。
やってはいけないのは、OAuthのアクセストークンをそのまま自社アプリのログインCookieとして使い回すこと。スコープ・失効・監査の責務がごちゃ混ぜになります。OAuthは「玄関で本人確認を外注する」だけ。家の中の鍵(セッション)は自分で管理します。OAuthフローの詳細はClaude CodeでのOAuth実装で掘り下げています。
ここで試せる:最小テスト
口で言うだけだと信用ならないので、振る舞いをテストで固定します。パスワード照合・セッションの生成と破棄・Cookie属性・Zod入力・CSRFを小さく確認するVitestです。npm install zod bcryptjs と npm install -D vitest typescript @types/node を入れれば動きます。
// test/auth.test.ts — 認証の土台を機械で守る
import { beforeAll, describe, expect, it } from "vitest";
beforeAll(() => {
process.env.NODE_ENV = "test";
process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});
describe("auth primitives", () => {
it("ハッシュ化と検証ができる", async () => {
const { hashPassword, verifyPassword } = await import("../lib/auth/password");
const hash = await hashPassword("correct-horse-battery-staple");
await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
});
it("署名付きセッションを発行・破棄できる", async () => {
const { createSession, destroySession, getSession } = await import("../lib/auth/session");
const session = createSession("user_123", "admin");
expect(getSession(session.token)?.role).toBe("admin");
destroySession(session.token);
expect(getSession(session.token)).toBeNull();
});
it("Cookie属性を明示している", async () => {
const { sessionCookieOptions } = await import("../lib/auth/session");
expect(sessionCookieOptions.httpOnly).toBe(true);
expect(sessionCookieOptions.sameSite).toBe("lax");
expect(sessionCookieOptions.path).toBe("/");
expect(sessionCookieOptions.maxAge).toBeGreaterThan(0);
});
it("Zodでログイン入力を検証する", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
expect(
loginInputSchema.safeParse({
email: "[email protected]",
password: "correct-horse-battery-staple",
}).success
).toBe(true);
});
it("状態変更はOriginとCSRFを確認する", async () => {
const { assertCsrf, assertSameOrigin, createSession, getSession } = await import(
"../lib/auth/session"
);
const created = createSession("user_123", "admin");
const session = getSession(created.token);
if (!session) throw new Error("missing session");
const request = new Request("https://example.com/api/admin/settings", {
method: "POST",
headers: { origin: "https://example.com", "x-csrf-token": created.csrfToken },
});
expect(() => assertSameOrigin(request)).not.toThrow();
expect(() => assertCsrf(request, session)).not.toThrow();
});
});
npx vitest runで5つ全部緑になれば、土台は機械で守られた状態です。仕様を変えたとき、ここが赤くなれば気づけます。
認可(RBAC)でadminの一点突破を避ける
認証ができたら、認可です。よくある雑な設計が、adminフラグ一個で全権限を分ける形。これだと、ちょっと権限を渡したいだけの人にまで全部開けてしまいます。
権限はuser:manage・billing:read・article:updateのように細かく割って、APIごとにデフォルト拒否(明示的に許可がなければ弾く)にします。テナント型のSaaSなら、ロールより先にtenantIdを確認するのが先です。他社テナントの請求書が読めてしまう事故は、ログイン機能の問題ではなく認可の不足です。冒頭の「認証と認可は別物」が、ここで効いてきます。権限設計の組み方はClaude CodeでのRBAC実装に分けて書きました。
僕がやらかした失敗3つ
正直に書きます。最初の認証実装は穴だらけでした。
ひとつ目が、冒頭の長命JWTをlocalStorageに置いたこと。退会者を止められず、後からセッション相当の仕組みを継ぎ足す羽目になりました。最初からブラウザはセッションで、と決めていれば避けられた回り道です。
ふたつ目は、ログイン失敗で「メールアドレスが存在しません」と返したこと。親切のつもりが、アカウントの有無を攻撃者に教えていました。今は成功も失敗も同じメッセージに揃えています。
みっつ目は、middlewareだけで管理画面を守った気になっていたこと。Cookieの有無しか見ていないので、APIを直接叩かれたら素通りでした。本物の判定はRoute Handler側に置き、ロールとCSRFを毎回確認するようにしてから、ようやく安心して公開できるようになりました。
よくある質問
Q. 結局、セッションとJWTはどっちを使えばいいですか? A. ブラウザで人間が使う通常ログインはセッションが安全側です。失効・強制ログアウトが楽だからです。JWTはモバイルや外部API向けの短命トークンに絞り、長命JWTをブラウザ保存しないのが基本です。
Q. パスワードはbcryptとArgon2idのどちらが良いですか? A. 新規ならOWASP第一候補のArgon2idが理想です。ネイティブ依存を避けたい・手軽さ優先ならbcrypt(ワークファクター10以上)でも十分実用的です。どちらにせよ平文とSHA-256単発は避けます。
Q. SameSite=LaxとStrict、どちらにすべき?
A. CSRF耐性はStrictが上ですが、外部リンクから来た直後にログイン状態が切れて見えるなど体験が硬くなります。多くのアプリはLaxを基本に、状態変更APIでCSRFトークンを併用するのが現実的です。
Q. CSRFトークンは本当に必要?SameSiteだけでは不十分?
A. SameSiteは強力ですが、古いブラウザや一部の遷移で完全ではありません。状態を変えるリクエストにはOriginチェックとCSRFトークンを足す二段構えが安全です。
Q. セッションをDBに保存すると遅くなりませんか? A. RedisなどKVストアを使えば実用上ほぼ問題になりません。それでも避けたい高頻度APIだけ短命JWTにする、という混在も実務では普通です。まずセッションで作り、ボトルネックが出てから最適化で十分です。
実際に試した結果
この型で一番効いたのは、「ログインAPIを作る」ことではなく、セッション・パスワード・Cookie・CSRF・認可・テストを同じ作業のひとかたまりとして扱うと決めたことでした。
以前の僕は、ログイン画面が動いた時点で満足して、Cookie属性の確認や権限テストを後回しにしていました。公開直前に「middlewareだけで管理画面を守っている」と気づいて青ざめる、をくり返していたわけです。今は、公式ドキュメントのリンク・Zodスキーマ・Cookie設定・失敗ケース・テストを最初にそろえてから手を動かします。すると実装の差分は小さくなり、レビューの会話も「ここのSameSiteは?」と具体的になりました。
賢い実装を探すより、転んでもケガしない土台を先に作る。Web認証は、これがいちばん速いというのが今の実感です。テンプレートや実務向けの教材が必要なら教材・テンプレート一覧を、チームで認証・RBAC・監査ログ・CIテストまで一気に整えるなら研修・導入相談をどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。