新しい httpOnly cookie を追加してデプロイすると、ログイン済みユーザーだけ401になる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
httpOnly かつ HMAC 署名の書き込み用cookieは、その追加を反映したデプロイより後に一度ログインした人にしか配られないため、デプロイ前からログイン中だったユーザーは画面上はログイン済みのまま、書き込みAPIだけが401を返す状態になる。
結論
書き込みAPI専用の新しいhttpOnly cookieを追加してデプロイしても、デプロイ前からログイン中だったユーザーには配られない。 そのcookieはログイン時にしか発行されないため、画面の出し分けに使っている別のcookieだけが有効な状態が残る。結果として画面はログイン済みのまま表示され続けるのに、書き込みAPIだけが401を返す。直し方は、401を受け取った時点で画面側のcookieを自分で捨ててログイン画面に戻し、次のログインで新しいcookieを配らせることだった。
症状
3DLab運営サイトの管理画面に、問い合わせチャットの知識をDBから編集できる機能を追加した。知識の中身はそのままチャットボットのシステムプロンプトに入るため、書き込みAPI(/api/admin/chat-knowledge/*)はページ表示用の認証とは別に、httpOnly + HMAC署名のcookieで守る設計にした。
このデプロイの直後、デプロイ前からログイン中だった運営担当が知識編集画面を開くと、画面自体はログイン済みとして表示されるのに、一覧の取得だけが失敗した。ログアウトしてパスワードを入れ直すと直ったが、ログインボタンもエラー画面も出ないため、「ログインしているのに保存だけできない」という分かりにくい状態になっていた。
原因
管理画面はもともとadmin_authというcookieだけでログイン状態を出し分けていた。
// app/admin/layout.tsx
useEffect(() => {
const authCookie = Cookies.get('admin_auth')
if (authCookie === 'true') {
setIsAuthenticated(true)
}
setLoading(false)
}, [])
// ...
if (response.ok) {
Cookies.set('admin_auth', 'true', { expires: 1 }) // 1日有効
setIsAuthenticated(true)
}
これはブラウザ側のJSがCookies.set()で立てているだけの値で、DevToolsから誰でも作れる。画面表示の出し分けには十分だが、書き込みAPIの鍵にはできない。
そこで今回の機能追加で、書き込みAPI専用のadmin_sessionを新設した。値は<失効時刻>.<HMAC>で、鍵は環境変数の管理者パスワードそのもの、比較はtimingSafeEqualで行う。
// lib/admin-auth.ts
const COOKIE_NAME = 'admin_session'
const TTL_MS = 24 * 60 * 60 * 1000 // 1日。/admin の admin_auth cookie と揃える
function sign(expiresAt: number, secret: string): string {
return createHmac('sha256', secret).update(String(expiresAt)).digest('hex')
}
export function issueAdminSession(secret: string): { name: string; value: string; maxAge: number } {
const expiresAt = Date.now() + TTL_MS
return {
name: COOKIE_NAME,
value: `${expiresAt}.${sign(expiresAt, secret)}`,
maxAge: Math.floor(TTL_MS / 1000),
}
}
このcookieは、パスワード認証の/api/authがPOSTで成功したときにしか配られない。
// app/api/auth/route.ts
const session = issueAdminSession(adminPassword)
response.cookies.set(session.name, session.value, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: session.maxAge,
})
つまり、この変更をデプロイした時点でadmin_authだけを持っていた既存ログインのブラウザは、admin_sessionを一切持っていない。admin_authは1日有効で残り続けるため画面はログイン済みのまま表示され続けるが、書き込みAPIが検証しているのはadmin_sessionだけなので、そちらは常に401を返す。運営担当から見ると、ログイン状態の表示と実際に書き込める状態がずれていることに、画面上の手がかりが何も無い。
直し方
401を受け取った時点で、画面側のadmin_authを自分で捨ててlocation.reload()し、ログイン画面に戻すようにした。
// 修正前(app/admin/chat-knowledge/page.tsx)
const r = await fetch('/api/admin/chat-knowledge')
if (r.status === 401) {
setError('セッションが切れています。一度ログアウトして、パスワードを入れ直してください。')
return
}
// 修正後
const r = await fetch('/api/admin/chat-knowledge')
if (r.status === 401) {
// ⚠ ここで「ログインし直してください」と出して終わらない。
// この機能を入れた直後は、画面上はログイン済みなのに書き込み用の cookie だけが
// 無い状態になる(admin_session はこの変更後の初回ログインで配られるため)。
// 人手で気づける状態ではないので、画面側の cookie を捨ててログイン画面に戻す。
Cookies.remove('admin_auth')
location.reload()
return
}
修正前はエラーメッセージを出すだけで、「一度ログアウトしてログインし直す」操作を運営担当自身に委ねていた。修正後はadmin_authを消してリロードするところまでをコード側で行うため、運営担当がすることは通常のログイン画面での再ログインだけになり、「ログアウトする」という手順そのものが手順書から消えた。
再発防止
admin_sessionを新設したとき、既存のadmin_authはあえて残したまま切り出した。/adminのログイン画面と/api/revalidateが今もそれを使っているため、cookieを1本に統合する変更は別の変更として先送りにしている。この設計自体が、新しいcookieを持たない古いログインがデプロイ後も残り得ることを前提にしたものだった。
書き込みAPIの認証を新設・更新する変更では、その場に居合わせなかった既存ログインのブラウザが古いcookieしか持っていない状態を必ず想定し、401を受けた側が自分でその状態から復帰できるようにしておく。今回のように「画面側のcookieを捨てて再読み込みする」処理を401ハンドラに書いておけば、運営担当に手順を1つも説明せずに済む。
よくある質問
Q1なぜ画面はログイン済みに見えるのに書き込みだけ失敗するのですか?
画面の出し分けに使うadmin_auth cookieはブラウザのJSがCookies.set()で作っているだけで、デプロイの前後を問わず有効なまま残ります。一方、書き込みAPIを検証するhttpOnlyのadmin_session cookieは、この機能を追加した回のデプロイより後に一度ログインした人にしか配られないため、デプロイ前からログイン中だった人は前者だけを持ち、後者を持たない状態になります。
Q2気づかず放置するとどうなりますか?
運営担当からは『ログインしているのに保存できない』としか見えず、ログインボタンもエラーメッセージも出ないため原因の切り分けに時間がかかります。手動での対処は一度ログアウトしてログインし直すことですが、画面はログイン済みに見えるため誰もログアウトを試そうと思わない、という状態が続きます。
Q3どう直せば運営担当の操作を減らせますか?
書き込みAPIから401が返ってきた時点で、画面側のadmin_auth cookieを自分で捨ててlocation.reload()でログイン画面に戻す処理を401ハンドラに入れます。ログイン画面に戻れば次のログインで新しいadmin_session が配られるため、運営担当は『ログアウトしてから入り直す』という手順を意識せずに、通常のログインをやり直すだけで直ります。
確認した環境
- Next.js ^15.4.10 / js-cookie ^3.0.5
- 2026-08-16 に本番反映直後の運用で発覚、同日中に解消
この記事の根拠
- TypeScriptファイル 113〜125行目コミット e8b5bf0
- ドキュメントファイル 78〜82行目コミット e8b5bf0
- ドキュメントファイル 208〜221行目コミット e8b5bf0
- TypeScriptファイル 1〜63行目コミット e8b5bf0
- TypeScriptファイル 31〜45行目コミット e8b5bf0
- TypeScriptファイル 19〜42行目コミット e8b5bf0
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。