SameSite=Laxで本番だけログインが切れた話。Cookie属性の正しい決め方
HttpOnly・Secure・SameSite・Domain・Path・Max-Age。Cookie属性の意味と決め方を、本番で踏んだ罠と一緒にNext.jsのコピペコードで解説します。
ローカルでは完璧に動いていたログインが、本番に出した瞬間、特定のユーザーだけ「ログインしてるのにすぐ弾かれる」状態になりました。
ログを見てもエラーは出ていない。認証ロジックも正しい。原因を追いかけて半日。犯人は SameSite=Lax と、決済サービスからのリダイレクトの組み合わせでした。外部サイトを経由して戻ってきたPOSTに、ブラウザがCookieを付けてくれなかったんです。
Cookieって、Set-Cookie で値を入れるだけの単純作業に見えますよね。でも実際は、HttpOnly・Secure・SameSite・Domain・Path・Max-Age という6つの旗を、用途ごとに正しく立てる小さな設計作業です。旗を1つ間違えるだけで、冒頭みたいに「特定の環境でだけ静かに壊れる」厄介なバグになります。
この記事では、6つのCookie属性が「それぞれ何を守っているのか」を身近な言葉で整理して、Next.js App Routerで動くコードに落とし込みます。僕が実際に踏んだ罠も全部出します。
この記事の要点
- Cookieの属性は6つ。HttpOnly=JSから読ませない旗、Secure=HTTPSだけに付ける旗、SameSite=他サイト経由で付けるかの規則、Domain/Path=届く範囲、Max-Age=寿命。
- 認証Cookieのほぼ正解は
__Host-session+HttpOnly+Secure+SameSite=Lax+Path=/。Domainは付けない。 SameSite=Laxは万能じゃない。外部サイト経由のPOST(決済・OAuth)でCookieが落ちる。必要な箇所だけNone(+Secure必須)にする。- サードパーティCookieはChromeでも縮小方向。「他サイトに埋め込まれた自分のCookie」は将来動かない前提で設計する。
- 分析・広告Cookieは同意後にだけ発行。認証Cookieと同じ箱に入れない。
まず6つの旗の意味を1つずつ
属性を丸暗記しても、本番で迷います。「これは何を守る旗か」で覚えると間違えません。表にするとこうです。
| 属性 | ひとことで言うと | 何を防ぐ/守る | 認証Cookieの推奨 |
|---|---|---|---|
HttpOnly | JSから読ませない旗 | XSSでセッションIDを盗まれる事故 | 必ず付ける |
Secure | HTTPSだけに付ける旗 | 平文HTTPでの盗聴 | 必ず付ける |
SameSite | 他サイト経由で付けるかの規則 | CSRF(他サイトからの勝手なリクエスト) | Lax(基本) |
Domain | どのホストに届くか | サブドメインへの意図しない流出 | 指定しない |
Path | どのパスに届くか | 範囲の絞り込み | / |
Max-Age | 何秒で期限切れか | だらだら生き続けるセッション | 短め(数時間) |
HttpOnly は、Cookieを document.cookie から読めなくする旗です。これが抜けていると、ページのどこか1か所にXSS(悪意あるスクリプトの混入)があるだけで、セッションIDが一発で外に流れます。認証Cookieに付け忘れる人が一番多い旗が、これです。
Secure は、HTTPS通信のときだけCookieを送る旗です。http:// には付かないので、盗聴されても中身が漏れません。ローカルの localhost は例外的にHTTPでも扱える場合がありますが、本番は必ずHTTPS前提で組みます。
SameSite が、今日の主役です。これは「別のサイトから飛んできたリクエストに、このCookieを付けるか」を決める規則です。Strict・Lax・None の3段階があります。
SameSiteの3段階と、僕が踏んだ罠
SameSite の3つの値は、こう理解すると間違えません。
Strict: 他サイトからの遷移には一切付けない。外部リンクから来た直後はCookieが付かない(=ログイン状態に見えない)。Lax: 普通のリンク遷移(GET)では付ける。でも他サイトからのPOSTや、画像・iframe埋め込みには付けない。多くのブラウザでこれが既定値です。None: クロスサイトでも全部付ける。ただしSecureが必須。
冒頭の事故は、まさに Lax の仕様どおりの挙動でした。決済サービスのページで処理を終えたユーザーが、決済側からPOSTで自分のサイトに戻される。このPOSTは「他サイト発のPOST」なので、Lax だとブラウザがセッションCookieを付けないんです。結果、戻ってきた瞬間に「未ログイン扱い」になる。
ローカルで再現しなかったのは、ローカルでは決済サービスを通さず同一サイト内で完結させていたからでした。Lax は普段は安全で快適なんですが、外部サイトを一度経由して戻ってくるPOSTには弱い。ここを知らないと半日溶かします(溶かしました)。
対策は2つです。ひとつは、戻り口を GET のコールバックにして Lax のまま通す。もうひとつは、その特定のCookieだけ SameSite=None; Secure にする。僕は、認証本体のCookieは Lax のまま守り、決済の戻りに使う一時的なstate用Cookieだけ None にして切り分けました。「全部 None」は事故のもとなので避けます。
__Host- prefix で範囲ミスをそもそも防ぐ
Domain と Path は「Cookieが届く範囲」を決めます。ここの設定ミスは、ログアウトでCookieが消えない・サブドメインに意図せず漏れる、といった地味な事故を生みます。
そこで便利なのが、Cookie名に付ける __Host- という接頭辞です。名前を __Host- で始めると、対応ブラウザは次を強制します。
Secureが必須Domainは指定不可(=そのホスト限定)Path=/が必須
MDNのSet-Cookieリファレンスでも、この __Host- prefixが範囲を最小化する手段として説明されています。何が嬉しいかというと、サブドメイン(evil.example.com)から同じ名前のCookieを上書きして、本体(example.com)のセッションをすり替える、といった攻撃の余地が消えることです。
認証Cookieは session より __Host-session と名付ける。これだけで Domain の付け忘れ・付けすぎ事故が構造的に起きなくなります。
Next.jsで認証Cookieを発行する(コピペで動く)
ここまでの旗を全部立てた、最小のログインRoute Handlerです。app/api/login/route.ts に置けます。Next.jsのcookies APIは2026年時点で非同期APIとして説明されていて、Cookieの設定・削除はRoute HandlerかServer Actionで行います(ストリーミング開始後は設定できません)。
デモ用にセッションをメモリ上のMapへ置いています。本番はRedisやPostgreSQLなど、再起動と複数インスタンスに耐えるストアへ差し替えてください。
import { createHmac, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
// 環境変数を起動時に検証する(SESSION_SECRETは32文字以上)
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
const SESSION_COOKIE = "__Host-session"; // __Host- でDomain指定不可・Path=/必須を強制
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; // 寿命は短め(8時間)
type SessionRecord = { userId: string; expiresAt: number };
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(12),
});
// セッションID=ランダム値 + 署名。改ざんを検知できる形にする
function createSessionToken() {
const id = randomBytes(32).toString("base64url");
const signature = createHmac("sha256", env.SESSION_SECRET).update(id).digest("base64url");
return `${id}.${signature}`;
}
async function authenticate(email: string, password: string) {
// 実際はDB照合 + bcrypt等でパスワード検証する
if (email === "[email protected]" && password === "correct-horse-battery-staple") {
return { id: "user_123" };
}
return null;
}
export async function POST(request: NextRequest) {
const body = loginSchema.safeParse(await request.json());
if (!body.success) {
return NextResponse.json({ error: "Invalid login payload" }, { status: 400 });
}
const user = await authenticate(body.data.email, body.data.password);
if (!user) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
// ログイン成功のたびに新しいIDを発行(セッション固定対策)
const token = createSessionToken();
sessions.set(token, {
userId: user.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true, // JSから読ませない
secure: true, // HTTPSだけに付ける
sameSite: "lax", // 他サイト発POSTには付かない=CSRFを抑える
path: "/", // __Host- の必須条件
maxAge: SESSION_MAX_AGE_SECONDS, // 寿命
// domainは指定しない(__Host- の条件)
});
return response;
}
このコードの肝は、最後の cookies.set に並ぶ旗です。6つの属性が、それぞれ「何を守っているか」を意識して並んでいます。SameSite=None をどうしても使う場面(OAuthコールバックや別ドメイン埋め込み)では、MDNのSet-Cookieが示すとおり Secure 必須です。自社アプリの普通の認証なら、まず Lax か Strict を選びます。
ログアウトとサーバー側の読み取り
ログアウトで一番多い失敗は、発行時と削除時のスコープがズレることです。Path=/ で発行したなら、削除も Path=/。Domain を付けて発行したなら削除も合わせる。ここが1文字でもズレると、ブラウザは「別のCookie」と見なして消してくれません。__Host- Cookieは Domain を指定しない分、このズレ事故が減ります。
import { NextResponse } from "next/server";
const SESSION_COOKIE = "__Host-session";
export async function POST() {
const response = NextResponse.json({ ok: true });
// 発行時と同じname・path・属性で、maxAge=0 を返して消す
response.cookies.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0, // ここを0にすると即時失効
});
return response;
}
本番ではCookieを消すだけでなく、RedisやDB側のセッション行も無効化します。Cookieだけ消してもサーバー側の記録が生きていると、トークンを保存していた攻撃者が使い続けられるからです。
サーバー側で読むときは、Server ComponentやRoute Handlerで cookies() を使います。Next.js 15以降は await cookies() と書きます。
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "__Host-session";
export default async function AccountPage() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
// Cookieが「ある」だけで信用しない
if (!sessionToken) {
redirect("/login");
}
// 実際はここでセッションストアに照会し、期限・無効化を確認する
return <main>Account dashboard</main>;
}
大事なのは最後のコメントです。Cookieの存在だけでログイン済みと判断しない。セッションストアで期限・ユーザーID・無効化状態を確認し、期限切れなら Max-Age=0 を返してブラウザからも消します。認証の全体設計はWeb認証の実装ガイドに分けて書いたので、トークンとセッションの使い分けはそちらが詳しいです。
サードパーティCookie廃止の流れと同意境界
もうひとつ、設計の前提として知っておきたいのが「サードパーティCookie」の縮小です。
サードパーティCookieとは、いま見ているサイトとは別ドメインが発行するCookieのことです。広告計測やクロスサイトのトラッキングで使われてきました。プライバシー保護の流れで主要ブラウザが制限を強めていて、SafariやFirefoxは既定でブロック、Chromeも縮小方向に動いています。つまり「他サイトに埋め込まれた自分のCookie」は、将来動かない前提で設計するのが安全です。
ここで効いてくるのが同意境界という考え方です。これは「同意なしで置けるCookie」と「同意を取ってから置くCookie」を分ける線のこと。欧州委員会のCookiesポリシーでも、認証・分析・同意の保存など用途ごとに扱いを分けています。地域差があるので法的助言ではありませんが、実装の原則ははっきりしています。認証・セキュリティCookieと、広告・分析Cookieを同じ箱に入れない。
具体的にはCookieを3レイヤーに分けます。
| レイヤー | 例 | 発行タイミング | 同意 |
|---|---|---|---|
| 認証・セッション | __Host-session | ログイン時 | サービス提供に必要 |
| UI設定 | theme, locale | 設定変更時 | 用途により説明 |
| 分析・広告 | _ga など | 同意後のみ | 必須 |
ポイントは、分析Cookieを拒否されてもログイン・カート・購入・CSRF保護は絶対に壊さないことです。同意バナーで「すべて拒否」を押したユーザーが、その瞬間にログアウトされたら欠陥です。CSPやセキュリティヘッダー側の対策とセットで考えたい人はセキュリティヘッダーの段階導入も合わせてどうぞ。
Max-AgeとExpires、ブラウザ挙動の落とし穴
寿命の指定には Max-Age(今から何秒)と Expires(この日時まで)の2つがあります。両方を指定すると、MDNによれば Max-Age が優先されます。サーバーとブラウザの時計ズレを避けたいので、アプリ側では Max-Age を主に使うのが扱いやすいです。
ブラウザ挙動で、踏みやすい落とし穴を並べます。
Set-Cookieはレスポンスヘッダー。フロントのJSからは読めない。- CORS越しの
fetchは、credentialsの指定を忘れるとCookieが送受信されない。 HttpOnlyCookieはdocument.cookieから読めないが、同一サイトのfetchにはブラウザが自動で付ける。SameSite=LaxはGETには付く。だからGETで状態を変える設計にすると、Laxでも他サイトから叩かれうる。状態変更はPOST/PUT/DELETEに。Pathは送信範囲の制御であって、同じホスト上の別パスから読まれない保証ではない。機密の隔離をPathに期待しない。
最後から2つ目、「GETで状態変更しない」は地味に重要です。SameSite=Lax をCSRF対策のつもりで入れていても、GET /delete?id=5 みたいなAPIがあると、Lax はGETにCookieを付けるので他サイトから実行されてしまいます。
Claude Codeに頼むときの指示と検証コマンド
Claude Codeに「Cookieを設定して」とだけ頼むと、動くコードは出ます。でも HttpOnly が抜ける、SameSite=None なのに Secure がない、ログアウトで Path がズレる、といった抜けが残りがちです。なので僕は、実装依頼の時点で条件を箇条書きで全部渡します。
Next.js App RouterのRoute Handlerで認証Cookieを実装してください。
条件:
- Cookie名は __Host-session
- HttpOnly, Secure, SameSite=Lax, Path=/, Max-Ageを明示
- Domainは指定しない
- ログイン成功時に毎回新しいセッションIDを発行(セッション固定対策)
- ログアウトでは発行時と同じPathでMax-Age=0を返す
- 状態変更APIがGETになっていないか確認
- 認証Cookieと、同意が必要な分析Cookieを混ぜない
生成されたら、ヘッダーを目で確認します。属性が文字列としてちゃんと出ているかが全てです。
curl -i -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"correct-horse-battery-staple"}'
期待する Set-Cookie はこの形です。6つの旗が揃っているか、1つずつ目視します。
Set-Cookie: __Host-session=...; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax
ログアウトも確認します。Max-Age=0 が返り、発行時と同じ Path=/ になっていれば第一段階は合格です。
curl -i -X POST http://localhost:3000/api/logout
Playwrightで詰めるなら、ログイン後に context.cookies() を見ると、ブラウザが実際に保存した httpOnly・secure・sameSite・expires まで確認できます。curl のヘッダー文字列と、ブラウザが解釈した結果、両方を見ておくと安心です。
よくある質問
Q. HttpOnlyを付けるとJavaScriptから読めなくて不便では?
A. 認証Cookieは、そもそもJSから読む必要がありません。サーバーへの fetch にはブラウザが自動で付けてくれます。JSから読みたいのはテーマや言語などのUI設定だけで、それらに権限情報を入れなければ安全です。
Q. SameSiteは結局LaxとStrictどっちにすべき?
A. 通常のアプリは Lax が無難です。外部リンクから来た直後でもログイン状態が保てます。管理画面や請求画面など、他サイト遷移を一切想定しない画面だけ Strict を検討します。None は外部サイト経由のPOSTが必要な場面だけに絞り、必ず Secure を付けます。
Q. ローカルでSecureを付けるとCookieが保存されない?
A. localhost は多くのブラウザで例外扱いされ、HTTPでも Secure Cookieを保存できます。それでも不安なら、ローカルもHTTPS(自己署名証明書など)で動かすと本番との差が消えてバグを減らせます。
Q. サードパーティCookieが廃止されたら認証は壊れる? A. 自社ドメイン内で完結する認証(ファーストパーティCookie)は影響を受けません。壊れるのは「他サイトに埋め込まれた自分のCookie」です。埋め込みウィジェットやクロスサイトの計測に依存している場合だけ、代替手段を検討します。
Q. セッションIDをlocalStorageに入れてはダメ?
A. やめた方がいいです。localStorage はJSから読めるので、XSSが1か所あるだけで盗まれます。認証セッションは HttpOnly Cookieに置き、権限はサーバー側で確認するのが鉄則です。
実際に試した結果
冒頭の「本番だけログインが切れる」事故以来、僕はCookieを書くとき、属性を1つずつ「これは何を守る旗か」と声に出して確認するようになりました。HttpOnlyはXSS、SecureはHTTPS、SameSiteはCSRFと外部遷移、Domain/Pathは届く範囲、Max-Ageは寿命。この6つを毎回ゼロから組み直すのではなく、__Host-session という1つの正解形をテンプレ化しておくのが結局いちばん速かったです。
Claude Codeに頼むときも、「安全にして」ではなく __Host-・SameSite の値・削除時の Path 一致・同意境界まで条件を書き下すと、修正回数が目に見えて減りました。属性の意味さえ握っていれば、Cookieはもう静かに壊れる箇所ではなくなります。
実装テンプレートをまとめて手元に置きたい人は教材一覧からどうぞ。チームのリポジトリに合わせて入れたい場合は研修・相談が早いです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。