playpark で自作している営業管理ダッシュボードは、しばらくBASIC_AUTH_USER / BASIC_AUTH_PASSWORDのBasic Authだけで保護していました。「実装コストゼロで手軽だし、社内ツールだからこれで十分」——正直、最初はそう思っていました(この判断が後で刺されることになるとは知らずに)。使い続けるうちに地味に効いてくる不便がいくつも出てきました。
ログアウトボタンがない(ブラウザがパスワードを覚えている限り、次に開いたときも素通り)。ログイン画面の見た目をカスタマイズできない(OSネイティブのダイアログなので、社名すら出せない)。ログイン試行に制限をかけられない(ブラウザの機能なので、失敗回数をアプリ側で数える手段がそもそもない)。
「そろそろ潰すか」となって、カスタムログインページ + Cookie認証への移行に着手しました。この手の実装は今どきAIに書かせるのが普通ですが、「認証を実装して」と丸投げすると、動くけど微妙に危ういコードが最初の一発で返ってくることがあります。今回はその実装コンセプトと、レビューで指摘された3つの落とし穴、そして次にやるなら最初からどう指示すればその落とし穴を避けられるかを紹介します。
この記事で学べること
- HMAC-SHA256でCookieに署名を付け、middlewareで検証する設計の全体像
- 自前で認証コードを書かせたとき、AIが一発目でやりがちな3つのアンチパターン(
===比較・クライアント依存の有効期限・シークレット未設定の放置) - そのアンチパターンを最初から回避するために、指示に足すべき一言
前提条件
- Next.js App Router(Server Actions有効)
middleware.tsでルート単位のアクセス制御を書いた経験- Web Crypto API(
crypto.subtle)を1回でも触ったことがある
設計のコンセプト
構成は3ファイルだけです。
lib/crypto.ts— HMAC署名の発行・検証・有効期限チェックをまとめた小さなモジュールlib/auth.ts(Server Action) — フォーム送信を受けて認証情報を比較し、通ったらCookieを発行middleware.ts— 全ルートに対して、リクエストのたびにCookieの署名と有効期限を検証
Cookieの中身は${user}:${timestamp}.${signature}という単純な文字列にしました。暗号化はしていません(中身は見えても構わない、改ざんされたら即バレる、という設計)。ログイン〜アクセス時の流れを図にするとこうなります。
土台になるのは署名の発行と検証、この2関数だけです。
export async function hmacSign(value: string): Promise<string> {
// Web Crypto API (crypto.subtle) でHMAC-SHA256署名を作る
}
export async function hmacVerify(value: string, signature: string): Promise<boolean> {
// hmacSignの結果と、定数時間比較で突き合わせる(落とし穴1参照)
}
BASIC_AUTH_USER / BASIC_AUTH_PASSWORDの環境変数はそのまま流用し、追加の依存パッケージなし、既存の運用(デプロイ時のenv設定)を変えずに済んだのは地味に助かったポイントです。3ファイルの実装全文は末尾に折りたたんであります。
ここまでで「Basic Authの代替」としては動きます。実際、最初のコミットのメッセージにはこう書いていました。
ブラウザネイティブの Basic Auth ダイアログを廃止し、カスタムログインフォーム + HMAC-SHA256 署名 Cookie 認証に移行。依存追加なし。既存の BASIC_AUTH_USER / BASIC_AUTH_PASSWORD 環境変数をそのまま使用。
動いた、で終わればよかったんですが、レビューで3つ刺されました。
レビューで直した3つの落とし穴
落とし穴1: パスワードを===で比較していた
最初の実装では、user === validUserのように素朴に等号比較していました。動作としては正しいんですが、===による文字列比較は先頭から1文字ずつ比較して、最初に不一致が見つかった時点で処理を打ち切る実装が一般的です。つまり、正解に近い文字列ほど比較にかかる時間がわずかに長くなる。
これがタイミング攻撃です。攻撃者が応答時間の微差を統計的に測定し続ければ、1文字ずつ正解に絞り込める理論上のリスクがあります。「===で十分セキュア」って言うけど、実際どうなの?というと、上記の理由でNoでした(ちなみに、この話を社内でしたら「え、そんな攻撃が現実的にあるの?」と半信半疑の反応でした)。このツールはVercelにデプロイしてインターネット経由でアクセスする構成なので、「社内向けだから」を脅威軽視の理由にはできず、「直せるものは直しておく」という話になりました。
直したのは「途中でreturnせず、必ず全バイトをXORで比較してから最後に判定する」定数時間比較への置き換えです。不一致が何文字目にあっても、比較にかかる時間はほぼ一定になります。
AIへの指示のポイント: 「パスワードを比較する関数を書いて」だけだと===や==が返ってきがちです。「タイミング攻撃を避けるため、定数時間比較(早期returnなしで全バイトを比較)で実装して」と一言足すと、最初からXOR比較の実装が出てきます。
一点補足すると、この実装はミドルウェアでも動かす都合上、Web APIのみで書いています。Node.js標準のcrypto.timingSafeEqualのほうが厳密ですが、node:cryptoはEdge Runtime(middlewareの実行環境)では使えないため、あえて自前実装を選びました。この制約も「Edge Runtimeで動かす前提」とAIへの指示に含めておかないと、最初からnode:cryptoベースの実装が出てきて後で書き直しになります。
落とし穴2: 有効期限をCookieのmaxAgeだけに頼っていた
CookieにはmaxAge: 60 * 60 * 24(24時間)を設定しているから期限管理はもう安心——のはずが、これはブラウザに対する削除指示にすぎません。ブラウザが正しく守ってくれる前提の話で、Cookie自体の署名が「有効期限を含んだ形」で検証されていなければ、盗まれたCookieをそのまま使い回されても署名検証だけは通ってしまいます。
そこで、ペイロードに埋め込んだタイムスタンプをサーバー側でも毎回チェックするようにしました。middleware側では有効期限チェックと署名検証の両方を満たさないと通しません。署名が正しくても期限切れなら弾く。ブラウザのmaxAgeとサーバーの再検証、二重チェックにしたことで「クライアントの言うことを信用しない」設計になりました。
AIへの指示のポイント: 「Cookieに有効期限を設定して」だけだと、maxAgeを設定して満足しがちです。「サーバー側でも有効期限を再検証する設計にして、クライアントが送ってきた情報を信用しない前提で」と指示すると、ペイロードにタイムスタンプを埋め込んでサーバー側で二重チェックする設計を提案してきます。
落とし穴3: シークレットキーが空でも黙って動いていた
これは一番ヒヤッとした指摘でした。AUTH_SECRET環境変数が未設定のまま本番にデプロイすると、HMAC署名の鍵が空文字になり、それでも署名処理自体は成功してしまいます。つまり「認証っぽく動いているが、実は誰でも偽装できるCookieを発行している」状態に気づかず本番運用してしまうリスクがありました。
まず入れたのは空シークレット時に警告ログを出す対応でしたが、これも「見逃されたら終わり」ということで、最終的には起動時にthrowして落とす実装まで強化しています。最初からthrowで良かったのですが、段階を踏んで直した過程として記録に残しておきます。
AIへの指示のポイント: 「必須の環境変数を読み込んで」だけだと、未設定時にフォールバック値(空文字やダミー値)を返す実装になりがちです。動くことを優先するとその方が「エラーで止まらない」ので一見親切に見えるからです。「認証の鍵として必須の環境変数なので、未設定なら起動時にthrowして」のように、失敗時の挙動まで明示しておくと最初から安全側の実装が出てきます。
動作確認
修正後は以下を確認しました。
- 正しいuser/passwordでログイン →
/にリダイレクトされ、Cookieが発行される - 間違ったパスワードでログイン → エラーメッセージが返り、Cookieは発行されない
- 有効なCookieのまま24時間経過をシミュレート(タイムスタンプを過去に書き換えたテストペイロード) → middlewareが
/loginにリダイレクトする - 署名だけ改ざんしたCookie → 検証がfalseを返し弾かれる
lib/crypto.tsはユニットテストも追加していて、定数時間比較の長さ違い・不一致パターン、有効期限チェックの期限内/期限切れ/不正フォーマット、署名の発行→検証のラウンドトリップまでカバーしています。
注意点・Tips(AIに認証コードを書かせるときのチェックリスト)
- 比較関数には「タイミング攻撃を避けて」と添える: 何も言わないと
===が返ってくる前提で指示する。 - 有効期限は「サーバー側でも再検証して」と添える: クライアントの
maxAgeだけで安心させない。 - 必須のシークレット・環境変数は「未設定ならthrowして」と添える: 動くことを優先したフォールバックを先回りで潰す。
- 実行環境の制約(Edge Runtimeなど)は先に伝える: 後から書き直しになりやすいポイントなので、
node:cryptoが使えない等は最初の指示に含める。
どれも「動くコードは一発で出てくるが、安全なコードは一言足さないと出てこない」タイプの落とし穴です。
まとめ
Basic Authからカスタムログイン + HMAC署名Cookie認証への移行自体は、依存パッケージを増やさずに数百行程度で完結しました。ただ、動くコードを書かせるのと、レビューに通るコードを書かせるのとの間には、今回のような「タイミング攻撃・サーバー側再検証・シークレット未設定」の3つの落とし穴が挟まっていました。どれも単体では小さな指摘ですが、積み重なると「動いているように見えて実は認証になっていない」状態を作ってしまう類のバグです。自前で認証を書く(書かせる)ときは、この3点を最初の指示に織り込んでおくと安全側に寄れると思います。
実装全文(lib/crypto.ts / lib/auth.ts / middleware.ts)
// lib/crypto.ts
const MAX_AGE = 60 * 60 * 24; // 24 hours (seconds)
export { MAX_AGE };
export function getSecret(): string {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error(
"[auth] AUTH_SECRET is not set — cannot sign sessions. Set AUTH_SECRET env var.",
);
}
return secret;
}
export async function hmacSign(value: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(getSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
return Buffer.from(signature).toString("hex");
}
export async function hmacVerify(value: string, signature: string): Promise<boolean> {
const expected = await hmacSign(value);
return constantTimeEqual(expected, signature);
}
// 全バイトを必ず比較してから、最後にまとめて判定する(落とし穴1)
export function constantTimeEqual(a: string, b: string): boolean {
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);
const maxLen = Math.max(bufA.length, bufB.length);
let diff = bufA.length ^ bufB.length; // 長さが違う時点でdiffは非ゼロ
for (let i = 0; i < maxLen; i++) {
diff |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0);
}
return diff === 0;
}
// サーバー側でも有効期限を再検証する(落とし穴2)
export function isTokenExpired(payload: string): boolean {
const lastColon = payload.lastIndexOf(":");
if (lastColon < 0) return true;
const ts = Number(payload.slice(lastColon + 1));
if (Number.isNaN(ts)) return true;
return Date.now() - ts > MAX_AGE * 1000;
}
// lib/auth.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { constantTimeEqual, hmacSign, MAX_AGE } from "./crypto";
const COOKIE_NAME = "session_token";
export async function login(
_prevState: { error?: string },
formData: FormData,
): Promise<{ error?: string }> {
const user = formData.get("user") as string;
const password = formData.get("password") as string;
const validUser = process.env.BASIC_AUTH_USER;
const validPass = process.env.BASIC_AUTH_PASSWORD;
if (!validUser || !validPass) {
return { error: "認証設定が不完全です。管理者に連絡してください。" };
}
// Constant-time comparison to prevent timing attacks
const userMatch = constantTimeEqual(user, validUser);
const passMatch = constantTimeEqual(password, validPass);
if (!userMatch || !passMatch) {
return { error: "ユーザー名またはパスワードが正しくありません" };
}
const payload = `${user}:${Date.now()}`;
const signature = await hmacSign(payload);
const token = `${payload}.${signature}`;
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: MAX_AGE,
path: "/",
});
redirect("/");
}
export async function logout(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
redirect("/login");
}
// middleware.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { hmacVerify, isTokenExpired } from "./lib/crypto";
const COOKIE_NAME = "session_token";
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname === "/login" || pathname.startsWith("/login/")) {
return NextResponse.next();
}
const token = req.cookies.get(COOKIE_NAME)?.value;
if (token) {
const lastDot = token.lastIndexOf(".");
if (lastDot > 0) {
const payload = token.slice(0, lastDot);
const signature = token.slice(lastDot + 1);
if (!isTokenExpired(payload) && (await hmacVerify(payload, signature))) {
return NextResponse.next();
}
}
}
const loginUrl = req.nextUrl.clone();
loginUrl.pathname = "/login";
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};



