DEV Community

BAOFUFAN
BAOFUFAN

Posted on

localStorage Clearing Pitfalls: A Stale Token Took Me 3 Hours to Debug

At 1:47 AM, my phone buzzed like a motor. A security colleague dropped a screenshot in the group: an already logged-out user was still hitting APIs with an old token. My gut reaction was impossible — the frontend calls localStorage.clear() right on logout. But the evidence was there; the on-call ops had already pulled the token’s logs. I dragged myself out of bed, VPN’d in, and replayed the scenario in Chrome DevTools over and over until dawn cracked and the root cause finally surfaced. That night forced us to build a full localStorage automation suite with Playwright, baking every clearing strategy into our release pipeline.

The problem: “ghost data” in localStorage

Our login flow is routine: backend returns a JWT, the frontend stores it in localStorage, and an axios interceptor attaches it to every request. On logout, the frontend calls localStorage.removeItem('token'). It looks watertight — so why did the token linger?

The reproduction path goes like this: the user has multiple tabs open in the same browser. They click logout in tab A; removeItem fires and A redirects to the login page. Tab B still sits on a page that requires authentication. It reads localStorage for the token — since A already cleared it, that read returns null. But tab B had already kept an in-memory copy of the token inside a Vuex store. Its axios interceptor goes on using that in-memory token for requests, the backend happily verifies the signature, and the token stays alive until we manually blacklist it.

Plain unit tests never catch this. Jest + jsdom gives you a single-page localStorage mock — no way to simulate multi-tab behaviour. Manual regression testing is even less reliable; nobody will keep five tabs open and re-run logout checks each time. The core issue: without a real browser’s multi-page environment, localStorage persistence and clearing strategies are a guessing game. You have to verify them with an end-to-end tool inside a real browser. That’s exactly where Playwright comes in.

Designing the approach: turning storage policies into repeatable assertions

We had a few hard requirements for the test tool:

  • Multi-page / multi-context: must simulate multiple tabs inside the same browser and manipulate their own localStorage.
  • Direct Storage access: not just clicking around — we must assert the actual values in localStorage during a test.
  • Cross-tab communication detection: listen for storage events and verify that other tabs react when one tab clears a key.
  • Stable and fast: the team doesn’t want to wait minutes per run.

Cypress is decent, but multi-tab support only matured around 10.x, and every test case lives in its own browser context — the isolation is so high it’s hard to mimic real tab mixing. Don’t even mention Selenium: operating localStorage there takes script injection, a maintenance nightmare. Playwright gives us native context.storageState() and page.evaluate(() => localStorage) — localStorage becomes a first-class citizen in tests. Creating multiple page instances within the same context naturally gives you a multi-tab environment.

The architectural idea is simple: split the localStorage policy into three assertion domains — persistence, active clearing, and passive clearing. Persistence verifies that the token survives a refresh/reopen; active clearing verifies that removeItem really runs on logout or account switch; passive clearing checks that when a token expires or another tab logs out, the in-memory copies in other pages are killed too. Each domain gets a test.describe. Test data uses fixed tokens and expiry times so we don’t depend on a real backend.

Core implementation: three scenarios you have to cover

1. Verifying localStorage persistence after login & refresh

This answers “if the user closes the tab and reopens it, is the token still there?” We simulate a login by writing a token, close the page, open a new one in the same context, and assert the token hasn’t gone missing.

import { test, expect } from '@playwright/test';

const AUTH_KEY = 'auth_token';
const SAMPLE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx';

test.describe('localStorage 持久化', () => {
  test('token 在页面刷新/重开后仍然存在', async ({ context, page }) => {
    // 写入 token 模拟登录成功
    await page.goto('https://your-app.example.com');
    await page.evaluate(({ key, value }) => {
      localStorage.setItem(key, value);
    }, { key: AUTH_KEY, value: SAMPLE_TOKEN });

    // 关闭当前页,新建页面(模拟重开标签页)
    await page.close();
    const newPage = await context.newPage();
    await newPage.goto('https://your-app.example.com');

    // 读取 localStorage 验证 token 持久化
    const token = await newPage.evaluate((key) => {
      return localStorage.getItem(key);
    }, AUTH_KEY);

    expect(token).toBe(SAMPLE_TOKEN);
  });
});
Enter fullscreen mode Exit fullscreen mode

Using context.newPage() inside the same browser context while closing the previous page perfectly mirrors the user reopening a tab. If we had stored the token in sessionStorage instead, this test would expose the flaw immediately — because sessionStorage is tied to the tab’s lifetime.

2. Verifying localStorage is cleared on logout

This scenario directly mirrors the outage: after the logout button is clicked, the token must disappear, and no in-memory copy should survive in any other open tab. We simulate two tabs — one performs logout, the other checks whether it can still read the token.


javascript
test.describe('localStorage 主动清除', () => {
  test('退出登录后所有标签页的 token 都被移除', async ({ context }) => {
    const pageOne = await context.newPage();
    const pageTwo = await context.newPage();

    // 两个标签页都预先写入 token
    await pageOne.goto('https://your-app
Enter fullscreen mode Exit fullscreen mode

Top comments (0)