"Never put a JWT in localStorage" is the most repeated piece of frontend security advice and the least measured. So I measured it.
258,117 source files. 1,423 touch web storage — that is the denominator, found by reading source text before any rule ran. They live in 189 public repositories.
Thirteen of those repositories put a credential in storage — eleven of them a bearer token. Seven of the thirteen are government digital services.
| | | |---|---| | Helsinki · kukkuu-admin | localStorage.setItem(APITOKEN, user.accesstoken) | | UK ONS · eq-author-app | localStorage.setItem("accessToken", user.ra) | | US GSA · srt-ui | localStorage.setItem('token', data.token) | | Canada · cds-snc | localStorage.setItem("token", response.accesstoken) | | France · betagouv ×2 | xsrfToken, NATIVETOKENKEY | | British Columbia · sso-requests | sessionStorage.setItem(TOKENSESSION, …) |
The non-government six are cloudflare-os, headlamp, mozillafxa, solid-playground, wixskills and a Twilio app that stores a passcode.
Thirteen out of 189 is about 7%. If you came expecting an epidemic, there isn't one — most code touching web storage keeps credentials out of it.
What makes seven percent interesting is where it lands. Public-sector frontends are built by rotating contractors against procurement deadlines, and they inherit whatever the OIDC tutorial did. The tutorial put the token in localStorage.
That is a mechanism, not an accusation. Two of the seven — Helsinki and the UK ONS — store a refresh token beside the access token, and that is the part that matters: an access token expires in minutes, a refresh token is a long-lived credential sitting where any injected script can read it synchronously. That is the whole argument: web storage is a trust boundary you share with every script on the page, and one DOM sink is all it takes to cross it. The six non-government hits are developer tools, playgrounds and internal consoles, where the threat model is genuinely different.
bcgovsso-requests uses sessionStorage, not localStorage — same XSS exposure, but the token dies with the tab instead of persisting. betagouvsante-psy stores an XSRF token, which is a different risk class entirely: it is meant to be readable by your own page, and it is not a bearer credential. That is why the count above says credential and not auth token: eleven of the thirteen are bearer tokens, and the two that are not are this XSRF token and a Twilio app passcode. bcgov is a bearer token — it is on this list for the store it chose, not for what it stores.
Counting them all as "a JWT in localStorage" would have been technically defensible and substantively wrong.
no-jwt-in-storage flagged 19 repositories. I read all 39 files. Six repos are false positives, and all six fail the same way — the rule matched an identifier name rather than proving a credential:
Also flagged: a co-browse session descriptor, a telemetry session, a sandbox id, and an example user object. That is 68% precision on real source.
I am reporting it because the thirteen are only believable if you know what the instrument does when it is wrong. A field study that publishes its hit rate and not its miss rate is a marketing page.
One more, worth its own line: no-cookie-auth-tokens fired zero times across all 1,423 files. A rule that never fires in a corpus this size is telling you about the rule, not the corpus.
Expect to read the hits rather than trust them — ground truth is the hard half of any audit like this. The corpus here is an adoption scan, not a random draw from GitHub, so "7 of 13" describes what I looked at. Point it at a different corpus and the mix will move.
Open your own repo and grep for setItem. Is the thing you find a bearer token, or did you just name a variable badly?
