Migrating From localStorage Tokens to Cookies

Most applications that keep tokens in localStorage did not choose it after weighing the trade-offs — it was the default in a tutorial, and by the time anyone reconsidered there were users, integrations and deadlines. This page is part of the session versus token authentication guide and lays out a migration that does not log anyone out or leave a window without protection.

Why the Move Is Worth Making

The argument is narrow and decisive. Script running in your origin can read localStorage synchronously, so a single injection produces a portable credential the attacker replays later from their own machine. The same script cannot read an HttpOnly cookie, so the same injection produces actions while the page is open and nothing that survives it.

What an injection yields under each storage model With script-readable storage the attacker obtains a portable credential replayable from anywhere; with an HttpOnly cookie the attacker can only act inside the compromised page for as long as it stays open. Script-readable storage one read exfiltrates the token replayable later, from anywhere theft outlives the page HttpOnly cookie unreadable by injected script actions only, while open ends when the session ends
Both outcomes are bad; only one follows the user home. That difference is the entire justification for the work.

The cost is that cookies are sent automatically, so you take on cross-site request forgery — a problem with a complete, well-understood defence. Trading an unsolvable exposure for a solved one is the right direction.

Sequence the Migration So Nothing Regresses

The order matters more than any individual step, because two of the orderings leave a window in which cookie-authenticated endpoints exist with no anti-forgery protection.

Migration sequence from stored tokens to cookie sessions Add anti-forgery protection first, then accept both credentials while issuing only cookies, then move client calls across, and finally remove the token path once telemetry shows no traffic. 1 · CSRF first before any traffic moves 2 · accept both issue only cookies 3 · move calls reads, then writes 4 · delete at zero traffic Doing step 1 after step 2 leaves a window with cookie sessions and no anti-forgery defence.
Step four is what actually closes the exposure. A dormant token path is a credential vending machine waiting to be rediscovered.

Step two is the one that keeps users signed in. Accept either credential at the boundary — cookie or bearer — and resolve both to the same identity context before any authorization runs, so the rest of the application does not know or care which arrived.

export async function resolveIdentity(req: Request) {
  const cookie = req.cookies["__Host-sid"];
  if (cookie) {
    metrics.increment("auth.path.cookie");
    return sessions.resolve(cookie);          // preferred path
  }
  const bearer = req.headers.authorization?.replace(/^Bearer /, "");
  if (bearer) {
    metrics.increment("auth.path.legacy_token");   // the burn-down number
    return tokens.resolve(bearer);
  }
  return null;
}

That counter is the migration’s progress bar. When legacy_token reaches zero and stays there for longer than your longest token lifetime, step four is safe.

Handling the Clients You Do Not Control

Browser code is easy — you ship it. The awkward part is anything else that authenticates against the same API: a mobile application with a release cycle, a partner integration, a CLI tool someone wrote two years ago.

Split the surface rather than forcing everyone onto cookies. Browser clients move to session cookies; non-browser clients keep bearer tokens, ideally with their own client registration and their own scopes. That is not a compromise but the correct end state: cookies suit browsers because the browser enforces their protections, and bearer tokens suit clients with a secure store and no cookie jar.

What must not happen is a single endpoint accepting either credential permanently. The cookie path re-introduces forgery exposure on an endpoint your tests exercise only through the header path, which is exactly the shape that survives review unnoticed. Separate the routes, or keep the dual acceptance strictly time-boxed.

Cleaning Up the Client

Deleting the storage code matters as much as changing the server. Remove every read and write of the token, the refresh timer that used it, and any interceptor that attached it — leaving them behind means a later change can quietly resurrect the pattern.

Clear existing values on the user’s next visit. Users who logged in a month ago still have a token sitting in storage; a small piece of start-up code that removes known keys cleans the installed base without a support conversation. Do it after the server stops accepting those tokens, so the removal cannot log anyone out.

Finally, add a lint rule or a test that fails if localStorage is used with an authentication-related key. The migration is a moment; the rule is what keeps the decision from being re-made by someone following the same tutorial next year.

What Changes on the Server

The client-side work is mostly deletion; the server picks up several responsibilities it did not have before, and planning for them avoids a mid-migration surprise.

You now issue and store sessions. That means a store with expiry, replication and a capacity plan — the operational work described in managing session state in Redis — plus a decision about idle and absolute timeouts that previously lived in the token’s expiry claim. Neither is difficult, but both are new infrastructure that needs monitoring.

You also own logout properly for the first time. With a token in the browser, “log out” often meant clearing storage, which ended nothing server-side. With sessions, logout deletes a record and can revoke upstream credentials, which is a genuine improvement — and a behaviour change worth testing, because users will notice that signing out on one device now affects what they can do on it.

Finally, you inherit CSRF. Add the token check to every state-changing route before any traffic moves, mount the middleware before the routes it protects, and write the negative test that posts from another origin and expects a rejection. This is the step teams postpone and the one that leaves a real hole if postponed.

Measuring the Migration

Three metrics that track a credential migration The share of requests on the legacy token path should fall to zero, the rate of 401 responses to browsers should stay flat, and the number of sessions created per login attempt should stay close to one. Legacy path share must reach zero a plateau means a leak 401 rate should stay flat a step means cookie config Sessions per login should stay near one higher means retries
The third metric is the early warning: clients that cannot store the cookie retry silently, and the ratio moves before anyone files a ticket.

Treat the cutover as something you observe rather than announce. Three numbers tell you where you are.

The share of requests arriving on each credential path is the primary progress metric, and it should fall towards zero on the legacy side as sessions naturally turn over. A plateau above zero means something is still issuing or refreshing old tokens — usually a forgotten code path, occasionally a cached bundle that clients have not reloaded.

The rate of 401 responses to browser clients tells you whether the new path is working. A step change after a deploy is the signal that something in the cookie configuration is wrong for a subset of users, which is far easier to catch from a graph than from support contacts.

The count of sessions created per login attempt catches a subtler failure: a client that fails to receive or store the cookie will retry, creating several sessions for one user in quick succession. Watching that ratio during the rollout surfaces browser-specific problems — a Secure cookie on an environment served over plain HTTP, or a mis-set domain — long before anyone reports being unable to sign in.

Frequently Asked Questions

Will users be logged out during the migration?

Not if you accept both credentials while issuing only the new one. Existing sessions continue on the old path until they expire, new sessions arrive on the new path, and the changeover happens per user rather than all at once. The only forced logout would come from removing the old path too early — which is what the telemetry counter exists to prevent.

Do I need a backend-for-frontend to do this?

Not necessarily. If your API is same-site with the frontend, a session cookie issued directly by that API is enough. A backend-for-frontend becomes valuable when the API is on a different origin, when several upstream services are involved, or when you want the tokens held server-side rather than replaced by sessions entirely — the pattern is covered in implementing the BFF pattern for SPAs.

What about the refresh token?

It should never have been in the browser, and this is the moment to fix that. Move it server-side with the session, so the client holds only an opaque identifier and refreshing becomes something your server does on the user’s behalf. If a non-browser client genuinely needs one, keep it in the platform’s secure store rather than in anything a page can read.

How long should the dual-acceptance window be?

One maximum credential lifetime after the last token was issued, plus a margin. For most systems that is days rather than weeks. Keep it explicitly time-boxed with a date in the ticket, because a temporary dual path that nobody closes becomes the permanent state, and it is the state with the worst properties of both models.

Is sessionStorage a safer alternative?

Marginally, and not enough to matter. It is still readable by any script in the origin, which is the property that makes the theft portable; it merely shortens the window to the life of the tab. If the choice is between the two, prefer in-memory storage over either — but the real answer is to keep the credential where script cannot reach it at all.

Keep all three on one dashboard for the duration and take it down when the legacy counter has been at zero for a full credential lifetime — at which point the migration is genuinely finished rather than merely deployed.