Restorable but not shareable
Engineering Patterns

Restorable but not shareable: hiding a token in history.state

A result that survives refresh and the back button but cannot be shared by copying the URL. A forgotten browser API, a header, and one deliberate deletion.

The result page of our new public feature is reachable by one thing: an opaque token. No login, no cookie, no account. Whoever holds the token can read the report and, if they choose, unlock the detailed version. The token is the authorisation. That is a capability token, and capability tokens come with a rule you cannot negotiate with: wherever the token goes, the access goes too.

We wanted two things from that page, and they pulled in opposite directions.

First, it should behave like a real page. Refresh, and your report is still there. Hit back, you get the form; hit forward, the report returns. Users expect this and punish you when it is missing.

Second, the report is derived from something personal the visitor gave us. So it must not be shareable by accident. Copy the address bar and paste it to a friend, and the friend should see nothing.

Our first design broke the second requirement in the most ordinary way possible: it put the token in the URL. ?scan=…. Refresh worked beautifully. So did forwarding the link.

Where the token could live

There are only so many places a browser can keep a value across a reload, and each one has a different sharing story.

The URL. Survives everything, and that is the problem. It is in the address bar, in the Referer of any outbound click, in browser history, in every proxy and server log along the way, and one Cmd-C away from a stranger.

A cookie. Survives reloads and is not in the URL. But this is a public marketing page with a strict cookie-consent posture, and a cookie is a broader surface than we needed for a value that only one tab should ever hold.

localStorage. Not shareable by URL, survives reloads. But it is per-origin, not per-tab: open the site in a second tab and the token is there too, and it lingers after the tab closes with no way for the app to clean up (there is no "page closed" hook you can rely on).

history.state. This is the one everyone forgets. Every entry in the browser's history stack can carry a JavaScript object, set with pushState or replaceState. It is preserved per entry, it survives a reload of that entry, it is restored on back and forward, and it is not part of the URL. Copy the address bar and the state object does not come with it. Open a new tab and it is not there. Close the tab and it is gone.

That is exactly the shape we wanted. Restorable within the tab that created it, inert everywhere else.

The plumbing

On a successful submit, instead of navigating anywhere, we push a new history entry at the same URL with the token in its state:

window.history.pushState(
  { ...window.history.state, scan: token },   // merge, don't replace
  '',
  window.location.href,
)

The merge matters if you are inside a framework router. Next.js keeps its own keys in history.state; overwrite the object and client-side navigation gets confused. Spread the existing state, add your key, leave the URL untouched so usePathname and friends do not fire.

On mount and on popstate, read the token back and, if present, restore the report (show a skeleton, fetch, render). If absent, show the form. Back from the report pops to an entry with no scan key, so the form returns; forward pops to the entry that has it, so the report returns. Refresh reloads the current entry, state intact.

"Scan another" and any terminal failure replaceState the key away, so a dead token does not haunt the history stack.

Getting the token to the server without a URL

The token still has to reach the API, and "not in the URL" has to hold there too. A path segment like /scans/<token> lands in every access log the request touches: the Rails request log, the reverse proxy's access log, anything in between. For a value that unlocks a report for 24 hours, that is a lot of places to leave a copy.

So the API routes became fixed and token-less (GET /scans/result, POST /scans/unlock) and the token rides in a request header. Two follow-ups came with that, and both are easy to forget:

  • Rails filters the header as a parameter, so it does not print in the request log.
  • The reverse proxy logs request headers in full by default; only the usual suspects (Cookie, Authorization, Proxy-Authorization) are blanked. A custom header needs an explicit delete in the access-log filter, or you have simply moved the leak from the path column to the headers column.

And then we deleted the cache

The last piece is what we removed. The first design also kept a copy of the report body in localStorage, for an instant paint on reload. We took it out.

Partly because of retention (a browser copy cannot honour a server-side deletion promise; that is its own post). But also because of the principle above: the report is what the token protects. A local copy of it is a second capability, one that lives outside the token's lifetime and outside the tab. Once the token was properly scoped to the tab, keeping the payload somewhere broader undid the point.

Now the tab holds a token, the server holds the report, and a reload costs one fetch behind a skeleton. Nobody has noticed the fetch.

The takeaway

When you have a capability token and a "make it feel like a page" requirement, you do not have to choose. Ask what scope should the capability have? If the answer is "this tab, until the user leaves", history.state is the storage that matches. It is the only one that is restorable and non-shareable at the same time, and it has been in every browser for over a decade.

Then follow the token everywhere it travels (URL, logs, local storage, proxies) and check that each place respects the same scope. The token in history.state was a ten-line change. Chasing it out of the URL, the request log, the proxy log and the browser cache was the rest of the afternoon, and it was the part that mattered.