Back to Blog
July 28, 2026

Our own app stored auth tokens in localStorage. Here's what we changed.

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

In June we pointed Penetrify at our own application, the way we do before a bigger release. The scan came back with a problem in our Cognito login flow, and when I pulled on that thread it turned into something I did not enjoy reading: the Penetrify dashboard kept Cognito access and refresh tokens in localStorage, in plaintext, readable by any JavaScript running on the page.

We did not sit down and decide that. It is the default of the auth library we used, and we never questioned it. That is the part worth writing about.

What the finding actually was

Our dashboard is a single-page app. On login it received tokens from AWS Cognito and the SDK put them in browser storage, which is where SDKs put them when there is no server involved in the session. Any script with access to the page could read them.

That means one cross-site scripting bug anywhere in the app, one malicious browser extension, or one compromised npm dependency, and an attacker walks away with both tokens. The access token gets them into the account. The refresh token keeps them there, because it stays valid long after the session looks closed, until something explicitly revokes it.

For our users, the blast radius was billing details, scan results for their applications, and affiliate payout data. Nobody exploited it as far as we can tell. That is not the point. It was reachable and it was our own default to fix.

Why encrypting localStorage is not the fix

The first instinct is to encrypt the tokens before storing them. It does not help. The decryption key has to live in the same JavaScript the attacker is already running. You have added a step, not a boundary.

The second instinct is to move the tokens into a cookie from the client side. That does not help either. A cookie set by JavaScript is readable by JavaScript. The property that matters is HttpOnly, and only a server can set it, through a Set-Cookie response header. If your fix runs in the browser, it is not a fix.

What we shipped instead

We moved the whole session to server-set cookies, using a token-handler pattern inside the backend we already had:

  • The FastAPI backend performs the Cognito calls and returns the session as cookies with HttpOnly, Secure and SameSite set. The browser never sees a token.
  • The auth middleware reads the access token from the cookie instead of an Authorization header.
  • Because cookies are sent automatically, mutating requests now carry a CSRF token header, which the backend verifies.
  • Logging out performs a global sign-out on the Cognito side, so a token that leaked earlier dies with the session instead of outliving it.
  • The auth SDK is gone from the browser entirely.

We did not need a new service for this. Our static app and our API are already same-origin: CloudFront serves the app and routes /api/* to API Gateway. A first-party cookie is therefore sent on every API call with no extra infrastructure.

We looked at the vendor-blessed path first, which would have meant adopting a hosted login UI, and rejected it. It would have thrown away our own login, MFA and password-reset screens to solve a problem we could solve in our existing backend.

What it cost

About a week of work, and no visible change for users. That is the honest summary of most security work, and it is exactly why this kind of thing gets postponed indefinitely at a startup. Nothing on the roadmap moves. No customer asks for it. The only thing that changes is what a single injected script can do to your users.

One practical detail that made it safe to ship: during the rollout the backend still accepted the old header-based path, so reverting the frontend deploy was a one-step rollback if anything went wrong.

Check your own app. It takes about 30 seconds.

Open your app, log in, then open DevTools:

  • Application tab, Local Storage. Look for long values with two dots in them. That shape is a JWT.
  • Check Session Storage in the same panel. Same problem, shorter lifetime.
  • In the console, type document.cookie. Anything that appears there is readable by JavaScript by definition, so a session cookie you can see in that output is not protecting you.

If you find a token, that is a finding. Not a theoretical one, and not one that a green dependency audit or a clean static analysis run would have told you about.

Why we are publishing this

Two reasons, and the second one is about our own product.

First, defaults are decisions somebody else made for you. They arrive with a library, they are convenient, they are almost never a threat model. Ours was a reasonable default for a tutorial and a bad one for an app holding payment data.

Second, a scan is a pointer, not a verdict. Ours flagged the login flow and proved it was reachable. It did not hand us the sentence "your auth library defaults to localStorage, and that is the thing to change". Getting from the flag to the fix meant opening our own auth path and reading it. That is also how we expect you to use our reports: the finding tells you where to look and shows the request that proves it, then you decide what the right fix is in your architecture.

So: run the scan on your own app, then read the code it points at. We did both, and neither half would have been enough on its own.

If you want the outside-in view of your app right now, our free security check takes about 60 seconds and needs no signup. If you want the full report with proof and fixes, the first scan is $29 and credits toward a plan. And if you would rather do the free thing we just described, open DevTools and go look at your own local storage. That one costs nothing.

Frequently Asked Questions

What types of vulnerabilities does Penetrify detect?

Penetrify detects all OWASP Top 10 vulnerability categories including SQL injection, XSS, CSRF, IDOR, broken authentication, security misconfigurations, and sensitive data exposure. It also tests API security, session management, and common misconfigurations in Supabase, Firebase, and Bubble.

How long does an AI penetration test take?

A quick scan completes in 15–30 minutes. A standard scan runs 1–2 hours with broader coverage. A deep scan can run several hours for complex applications.

What does a Penetrify report include?

Every report includes an executive summary, overall security score, severity-classified findings (Critical, High, Medium, Low), step-by-step reproduction steps, and concrete remediation guidance written for developers — not compliance officers.

Related articles

DAST Alternatives in 2026: When Dynamic Scanning Isn't Enough (and What to Use Instead)
DAST scanners miss auth flows, business logic, and modern APIs. Here's an honest comparison of DAST vs SAST, IAST, PTaaS, and AI autonomous penetration testing-and when to use each.
What an autonomous pentest agent found in 3,847 apps — and what your scanner didn't
A data breakdown of 47,291 exploitation-validated findings, with methodology and limitations. 91% of the SQL injection we found shipped despite a SAST gate in CI; 78% of critical findings needed no login.
Indirect Prompt Injection to Data Exfiltration: When the Model Has Tools
Prompt injection on its own is a curiosity. Prompt injection reaching a tool that holds real credentials is a breach — and the instruction does not have to come from your user. It can arrive inside the document your app was asked to summarise.

Explore more