Rebounder Tech Blog

Written by the people who actually run these systems in production.

A New httpOnly Cookie 401s Only Already-Logged-In Users

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

An httpOnly, HMAC-signed write cookie is only issued to people who log in after the deploy that added it, so anyone already logged in stays signed in on screen while only the write API returns 401.

Conclusion

Adding a new httpOnly cookie for a write API and deploying it does not give that cookie to anyone already logged in. It is only issued at login, so what remains valid is the separate cookie the UI uses to decide what to show. The screen keeps rendering as signed in while only the write API returns 401. The fix was to drop the UI’s cookie on a 401 and return to the login screen, letting the next login issue the new one.

Symptom

An admin site gained a feature for editing the enquiry chatbot’s knowledge from the database. That knowledge goes straight into the chatbot’s system prompt, so the write API (/api/admin/chat-knowledge/*) was designed to be protected by an httpOnly, HMAC-signed cookie separate from the page-display authentication.

Right after the deploy, staff who had been logged in beforehand opened the knowledge editor. The page rendered as signed in, but fetching the list failed. Logging out and re-entering the password fixed it, but with no login button and no error screen, the state read as “I am logged in and only saving fails”.

Cause

The admin UI originally decided its signed-in state from a single cookie, admin_auth.

// 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 }) // valid for one day
  setIsAuthenticated(true)
}

This is just a value set by browser JS with Cookies.set(); anyone can create it from DevTools. Fine for deciding what to display, useless as a key for a write API.

So this change introduced admin_session for the write API. Its value is <expiry>.<HMAC>, keyed on the admin password from the environment, compared with timingSafeEqual.

// lib/admin-auth.ts
const COOKIE_NAME = 'admin_session'
const TTL_MS = 24 * 60 * 60 * 1000 // one day, matching the admin_auth cookie for /admin

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),
  }
}

It is issued only on a successful POST to the password endpoint /api/auth.

// 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,
})

So at the moment of the deploy, a browser already logged in held admin_auth and no admin_session at all. admin_auth lasts a day, so the screen keeps rendering as signed in, but the write API only verifies admin_session and always returns 401. From the operator’s side there is nothing on screen to indicate that the displayed login state and the ability to write have diverged.

The fix

On a 401, drop admin_auth and location.reload() back to the login screen.

// pre-fix (app/admin/chat-knowledge/page.tsx)
const r = await fetch('/api/admin/chat-knowledge')
if (r.status === 401) {
  setError('セッションが切れています。一度ログアウトして、パスワードを入れ直してください。')
  return
}
// post-fix
const r = await fetch('/api/admin/chat-knowledge')
if (r.status === 401) {
  // Do not stop at telling the user to log in again.
  // Right after this feature ships, the screen is signed in while the write cookie is
  // simply absent (admin_session is only issued on the first login after this change).
  // Nobody can notice that by eye, so drop the UI cookie and go back to the login screen.
  Cookies.remove('admin_auth')
  location.reload()
  return
}

The pre-fix version only showed a message, leaving “log out and log back in” to the operator. The post-fix version removes admin_auth and reloads in code, so all the operator does is log in again on the normal login screen. The step “log out first” disappeared from the runbook entirely.

Preventing a repeat

When admin_session was introduced, the existing admin_auth was deliberately kept. The /admin login screen and /api/revalidate still use it, so consolidating to a single cookie was deferred to a separate change. That design already assumed old logins without the new cookie would survive the deploy.

Whenever you introduce or update authentication on a write API, assume there are browsers already logged in that hold only the old cookie, and make sure the side receiving the 401 can recover on its own. Putting “drop the UI cookie and reload” into the 401 handler means not a single step has to be explained to the operator.

よくある質問

Q1Why does the UI look signed in while only writes fail?

The admin_auth cookie the UI keys off is set by browser JS with Cookies.set() and survives the deploy. The httpOnly admin_session cookie the write API verifies is only issued to people who log in after that deploy, so earlier sessions hold the first and not the second.

Q2What happens if it goes unnoticed?

Staff only see 'I am logged in but cannot save'. No login button, no error, so isolating the cause takes time. The manual fix is to log out and back in, but because the screen looks signed in, nobody thinks to try logging out.

Q3How do I remove the manual step for staff?

On a 401 from the write API, drop the admin_auth cookie yourself and call location.reload() to return to the login screen. The next login issues a fresh admin_session, so staff simply log in again without ever being told to log out first.

確認した環境

  • Next.js ^15.4.10 / js-cookie ^3.0.5
  • Found in operation right after the 2026-08-16 production release, resolved the same day

この記事の根拠

  • TypeScriptファイル 113〜125行目コミット e8b5bf0
  • ドキュメントファイル 78〜82行目コミット e8b5bf0
  • ドキュメントファイル 208〜221行目コミット e8b5bf0
  • TypeScriptファイル 1〜63行目コミット e8b5bf0
  • TypeScriptファイル 31〜45行目コミット e8b5bf0
  • TypeScriptファイル 19〜42行目コミット e8b5bf0

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。