Blog

Our App Just Wouldn't Let Go

The solution was to delete the cookie from within the server action. Whenever cookies() is modified within a server action, Next itself emit the deletion Set-Cookie on the action's response, which the browser will definitely receive this time.

Victor Ohachor ·

Some bugs kiss you on your right cheek, only to be your Judas...

One of such I experienced today. I had just integrated Cloudflare's Turnstile to prevent non-users from spamming the prompter, which shows up on the landing, product, and about pages.

I wanted to test my work in production, since I had disabled reCAPTCHA in the development environment. Hence, I signed out, found myself on the landing page (as expected), smile, and typed into the prompter. As I typed, I thought:

"Turnstile is not showing up... Hmmm... That's weird..."

I had already set TURNSTILE_SECRET_KEY on the backend and NEXT_PUBLIC_TURNSTILE_SITE_KEY on the frontend. I was sure that Turnstile was working before I deployed.

So, I assumed there was a caching problem.

I clicked GENERATE anyway.

Usually, as a non-user, when you click the prompter's button, you are sent to /result/[searchId]?sid=<sid>...But I found myself on the dashboard result page (/dashboard/history/[searchId]). Since I was signed out, this shouldn't have happened.

But...but... it did! Why?!

As a debugging rule, I clicked the SIGN OUT button. I needed to replicate what happened. Since my assumption was that something could be wrong with the sign out flow, I clicked the DASHBOARD button again once on the landing page.

To my horror, I found myself on the dashboard result page again, a new search initiated using same prompt from before.

The best part about discovering disastrous bugs like this is when you find it before your users. Assuming a user had first initiated a search as a non-user, then signed in, signed out again, and then signed in, they would experience this bug.

But the likelihood of that happening is pretty low. But don't underestimate malicious/curious users, users always looking for something interesting or a way to mess you up.

Screenshot of duplicate searches caused by the bug described in this blog post

So... What is the Bug?

There were two (2) bugs. Ordinary bugs. Each mild on its own. But they had a multiplying effect that felt supernatural.

Sign-out was an amazing illusion

I was actually signed out when I clicked the SIGN OUT button. One moment, I was on the dashboard; another, I was on the landing page. Isn't that the sign-out experience?

In fact, the app made a request to the logout API endpoint. So, what happened?

The logout was a Next.js server action:

export async function signOut() {
  const jar = await cookies()
  const cookie = jar.getAll().map((e) => `${e.name}=${e.value}`).join("; ")
  try {
    await fetch(`${API_ORIGIN}/api/v1/auth/logout`, {
      method: "POST",
      headers: cookie ? { cookie } : {},
    })
  } catch {
    // fall through to local redirect either way
  }
  redirect("/")
}

The backend did an excellent job: it responded with Set-Cookie: sid=; Max-Age=0, which would have properly expired the session cookie. I said "would have" because the browser never saw it.

Logout is a server action, hence the fetch happened server-to-server. Response headers of a server-side fetch are for to inspect and are not forwarded to the client that initiated the action.

redirect('/') then makes the user believe they have been logged out, haha.

The solution was to delete the cookie from within the server action. Whenever cookies() is modified within a server action, Next itself emit the deletion Set-Cookie on the action's response, which the browser will definitely receive this time.

Hence adding the line before the redirect fixed the issue:

  // The API's Set-Cookie deletion header never reaches the browser through
  // this server-side fetch, so drop the session cookie locally too.
  jar.delete("sid")
  redirect("/")

Brief wey no gree die

This was the second bug, particularly responsible for the duplicate search. The landing page had a handoff pattern:

Here is the code snippet responsible for this:

sessionStorage.setItem(PENDING_BRIEF_KEY, value)
router.push(`/dashboard?brief=${encodeURIComponent(value)}`)

And the dashboard's GenerateAndGo component was supposed to consume it exactly once:

useEffect(() => {
  if (submittedRef.current || pending) return
  let brief = initialBrief          // <- from ?brief= URL param
  if (!brief) {                     // <- ONLY clears the stash on this path
    brief = sessionStorage.getItem(PENDING_BRIEF_KEY) ?? ""
    sessionStorage.removeItem(PENDING_BRIEF_KEY)
  }
  if (brief.trim()) void onSubmit(brief.trim())
}, [initialBrief])

Read it slowly: the removeItem lives inside the if (!brief) branch. On the main path (the path the prompter just took that was arriving with ?brief=), the stash was read from the URL and sessionStorage was not touched.

The fix was super simple... Make consumption unconditional, so that a later visit can't auto-start a phantom search.

let stored = ""
try {
  stored = sessionStorage.getItem(PENDING_BRIEF_KEY) ?? ""
  if (stored) sessionStorage.removeItem(PENDING_BRIEF_KEY)
} catch {
  stored = ""
}
const brief = (initialBrief || stored).trim()
if (brief) {
  submittedRef.current = true
  const id = setTimeout(() => void onSubmit(brief), 0)
  return () => clearTimeout(id)
}

Lessons???

You tell me!

Transmissions [0]

This channel is open to anyone with something worth saying. Every transmission is reviewed before it goes live, so give yours a moment to land.

Dead air so far, which means the first transmission on this post is yours to send.

Comments are public — be kind, be useful.

We use a few strictly necessary cookies (to keep you signed in and remember this choice), Cloudflare's bot check, and — only if you allow it — privacy-friendly analytics. No ads, no cross-site tracking. Details live in the privacy policy.