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,SecureandSameSiteset. The browser never sees a token. - The auth middleware reads the access token from the cookie instead of an
Authorizationheader. - 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.
