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.
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.
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
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.
Related
- Understanding session vs token authentication — the trade-offs behind the destination.
- Securing localStorage vs HttpOnly cookies — the exposure this migration removes.
- Mitigating CSRF attacks in modern SPAs — the protection that has to land first.