Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Is storing a JWT in localStorage safe? What to do instead

FIX / JWT STORAGE / 9 MIN READ

No, localStorage is not a safe place for a JWT, because any JavaScript that runs on your page can read it, so store the session token in an HttpOnly cookie that the server sets. A single cross-site scripting (XSS) bug is enough to copy every user token. The user can then lose the account, even after the XSS bug is fixed.

This guide explains the risk, how to check your app, and how to move the token to a safer place. It also shows the new risks of cookies and how to control them.

What does “JWT in localStorage” mean, and why is it a problem?

It means that your frontend code saves the token with localStorage.setItem and reads it back for each API request. The code is short, so AI coding tools often write it. The app works, and the risk stays hidden.

The problem is who can read localStorage. Every script that runs on your page can read it. This includes your own code, the code of your dependencies, and any script that an attacker injects. The OWASP HTML5 Security Cheat Sheet says: “Do not store session identifiers in local storage as the data is always accessible by JavaScript.” It adds that a single XSS bug can be used to steal all the data in these objects.

The OWASP XSS Prevention Cheat Sheet lists what XSS can do. It can lead to account impersonation, observing user behavior, loading external content, and stealing sensitive data. A stored token is one such item of data.

A stolen JWT is also a larger loss than a stolen page view. The attacker can copy the token to another computer and use it from there. The server cannot tell the difference, because the token is valid. The token works until it expires, or until you block it.

Where can an XSS bug come from in a vibe-coded app? Common sources are:

  • A field that the app shows with dangerouslySetInnerHTML or v-html.
  • Markdown or rich text from users or from an AI model that the app renders as HTML. See LLM-rendered HTML and markdown.
  • A third-party script, such as a chat widget, an analytics tag, or a package that was changed in an update.
  • A URL parameter that the page copies into the HTML.

You do not need to find the bug today to have the risk. You only need to be sure that no such bug will ever exist. That is hard to guarantee.

How do I check if my app stores the JWT in localStorage?

You can check in two minutes with the browser.

  1. Open your live app and sign in.
  2. Open DevTools and select the Application tab. In Firefox, select Storage.
  3. Open Local Storage and select your site.
  4. Look for keys that hold a long string with two dots, such as eyJhbGciOi.... Look for the names token, access_token, jwt, auth, or a key that starts with sb-.
  5. Open Session Storage and do the same check.
  6. Open Cookies and check how the session cookie is set. A safe cookie has HttpOnly and Secure checked.

You can run this check in the console:

Object.keys(localStorage).filter((k) =>
  /token|jwt|auth|session|^sb-/i.test(k),
);

If the list has a session key, a script can read the token. Test it:

JSON.stringify(localStorage).match(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g);

A result with a long string means that any script on the page can read the token. The value in the string is a signed token. Do not paste it into a chat or a public tool.

Then check your code. Search the repository for localStorage.setItem, sessionStorage.setItem, and Authorization: Bearer. Also check how your auth library saves the session. Many client libraries save the session in browser storage unless you change the setting. Read the documentation of your library.

Warning: Treat any token that you copy from the browser as a password. Do not share it, and do not store it in a note.

What do I do first? Fix it now, in order of urgency

Do not move the token before you remove the larger risk. The first steps lower the chance that a script can run on your page. The next steps lower the damage if a script does run.

Caution: If you change the token storage, all users must sign in again, because the old tokens stay in the old place. Plan the change for a time with few users.

  1. Search for XSS sinks. Search the code for dangerouslySetInnerHTML, innerHTML, v-html, document.write, and eval. Remove each one that holds user or AI content, or sanitize the content first.
  2. Remove scripts that you do not need. Each third-party script on a page that handles a session can read the token.
  3. Shorten the token lifetime. Use an access token that expires in a short time, for example 15 minutes. A stolen token then works for less time.
  4. Move the session token to an HttpOnly cookie. See the next section.
  5. Add a Content Security Policy. It reduces the chance that an injected script runs.
  6. Add CSRF controls for the cookie. See the section after the next one.
  7. End old sessions. Revoke the refresh tokens for users who must sign in again.
  8. Check the result in DevTools. The session must not appear in Local Storage.

The MDN documentation for Set-Cookie says that HttpOnly forbids JavaScript from accessing the cookie, for example through Document.cookie. This mitigates attacks against XSS. A script cannot read the token and cannot send it to another server.

The same page says that the browser still sends an HttpOnly cookie with requests that JavaScript starts, for example with fetch(). This is important. An attacker script cannot steal the token, but it can still make requests as the user while the page is open. The cookie lowers the loss. It does not remove the XSS bug. You still need to fix the bug.

The difference is large in practice:

Property localStorage token HttpOnly cookie
A script can read the token Yes No
An attacker can use the token from another computer Yes No, only through the open page
The browser adds the token to requests No, your code does it Yes
CSRF risk No Yes, so add controls
Works with cross-origin APIs Easy Needs extra settings

The server must set the cookie in the response to the login request. The browser then stores it, and your JavaScript never sees the value. This example uses Express:

res.cookie("__Host-session", accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 15 * 60 * 1000,
});

Each flag has a purpose. The MDN page defines them:

  • HttpOnly stops scripts from reading the cookie.
  • Secure sends the cookie only over HTTPS. MDN notes that this attribute does not prevent all access to cookies, so it does not replace HttpOnly.
  • SameSite=Lax sends the cookie on same-site requests and on top-level navigations with safe methods. MDN says that it excludes fetch() requests and POST, PUT, and DELETE requests from other sites.
  • The __Host- prefix requires the Secure attribute, no Domain attribute, and Path=/.

For the API, read the cookie on the server and check the JWT there. Do not add an Authorization header from the browser. Use fetch with credentials: "include" if the API is on another origin. Then set the CORS headers with an exact origin and not *. See CORS with credentials for the rules.

Move a Supabase app to cookies

The Supabase server-side auth overview says that the client must store the user session in cookies instead of local storage. The framework quickstarts configure this for you. Use the official server-side package for your framework, such as the one in the Next.js or SvelteKit quickstart, and not a hand-written client.

Check these points after the change:

  1. The server creates the Supabase client for each request and reads the session from the cookies.
  2. The server checks the user with a call to the Auth server or with a verified JWT. Do not trust data that comes only from a cookie.
  3. No Supabase token appears in Local Storage.

Note that Supabase documents that cookies protect against script access only when the server controls them. If your app is a pure single-page app with no server, the browser code must read the token, and a cookie that JavaScript can read gives no protection. In that case, add a small server or a serverless function that handles sign-in and sets the cookie.

Use a short access token and a refresh token

A common design has two tokens:

  • A short-lived access token, for example 15 minutes.
  • A longer-lived refresh token in an HttpOnly cookie, limited to the refresh path.

The server issues a new access token from the refresh token. Rotate the refresh token at each use. If the server sees an old refresh token again, it ends the whole session. A stolen token then has a short life, and a reused one is detected.

Do not make the refresh token readable by JavaScript. A readable refresh token has the same problem as a long-lived access token in localStorage.

How do I handle the new risks of cookies?

A cookie solves the script access problem but adds a request forgery problem. The browser adds the cookie to any request to your site, including one from another site. This is cross-site request forgery (CSRF).

Use these controls together:

  1. Set SameSite=Lax or Strict. This blocks the cookie on most cross-site requests.
  2. Check the Origin header. For each request that changes data, compare the Origin header with your own domain. Reject other values.
  3. Use a CSRF token. For forms and for sensitive actions, require a random token in a header or a hidden field.
  4. Do not change data with GET requests. SameSite=Lax sends the cookie on top-level GET navigations.
  5. Use the right CORS settings. Never combine a wildcard origin with credentials.

A small check in an Express middleware:

app.use((req, res, next) => {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
  if (req.get("Origin") !== "https://your-app.example") {
    return res.status(403).json({ error: "Bad origin" });
  }
  next();
});

Replace the domain with your real one. Your host or framework may already offer CSRF protection. Check before you add your own.

How do I limit the damage of XSS?

Assume that a bug can exist. Add layers.

First, add a Content Security Policy. The OWASP XSS guide says that CSP should not be your primary defense. It recommends CSP as an additional layer. A basic policy that blocks inline scripts and unknown script hosts looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

Add each external host that your app needs, one by one. Test the policy in report-only mode first with the Content-Security-Policy-Report-Only header. A policy that is too strict breaks the page.

Second, use the framework escape rules. The OWASP guide says that modern frameworks help mitigate XSS with templating and auto-escaping, but that escape hatches such as dangerouslySetInnerHTML remain risks. Do not use them with data that you do not control.

Third, check the packages. A changed dependency runs with the same rights as your own code. Pin versions, review updates, and remove packages that you do not use.

How do I check the damage if a token was stolen?

You cannot see from the browser if a script copied a token. Look for signs on the server.

  1. Read the auth logs. Look for one user who signs in or makes requests from two countries or two devices in a short time.
  2. Check for requests with no matching login. A stolen access token has no login event.
  3. Check sensitive actions. Look at password changes, email changes, payment changes, and exports.
  4. Look for the XSS source. Search the content that users can write. Look for <script, onerror=, and javascript: in the database.
  5. Revoke the sessions. Sign out all users, or only the affected users. Rotate the JWT signing secret if you think the secret leaked, because that invalidates every token.

For other token flaws, such as forged tokens, read JWT alg none and kid traversal.

How do I stop this from happening again?

  1. Do not put session tokens in localStorage or sessionStorage. Use HttpOnly cookies.
  2. Keep access tokens short. Use refresh token rotation.
  3. Set a CSP and test it. Start in report-only mode.
  4. Ban raw HTML rendering in the project rules. Tell the AI coding tool to never use dangerouslySetInnerHTML or innerHTML with dynamic data.
  5. Review third-party scripts. Keep only the ones that you need.
  6. Add a test for the cookie flags. After each release, check HttpOnly, Secure, and SameSite in DevTools.
  7. Scan the live app. A scan reads the real responses and headers, not only the source code.

For more cookie and OAuth gaps, see cookie, TLS, and OAuth gaps. For the full list of auth controls, see authentication implementation.

When should I get help?

Get help in these cases:

  • You find a script that you did not add, or content in your database that holds script tags.
  • Users report actions on their accounts that they did not make.
  • Your app holds payment data, health data, or business data, and you cannot show that tokens were not copied.
  • Your app is a single-page app with no server, and you do not know how to add one for the cookie.
  • You use a custom JWT system, and you are not sure how it signs and checks tokens.

After the change, sign in and open DevTools. The session must be an HttpOnly cookie, and Local Storage must hold no token. Then run document.cookie in the console. The session cookie must not appear in the result.

Common questions

Is sessionStorage safer than localStorage for a JWT?
Only a little. A script on the page can read both. sessionStorage clears when the tab closes, but an XSS script can read it before that.
Does an HttpOnly cookie stop XSS?
No. It stops a script from reading the token. A script can still send requests, and the browser adds the cookie. Fix the XSS bug and add a CSP as well.
Do cookies cause CSRF problems?
Yes, they can. Set SameSite on the cookie and check the Origin header on requests that change data. Use a CSRF token for extra protection.
Is it safe to keep the JWT only in a JavaScript variable?
It is safer than localStorage, because the token is gone after a reload. A script on the page can still reach it. Use it only with a refresh token in an HttpOnly cookie.
Can I keep localStorage if my app has no XSS bug?
You cannot prove that the app has no XSS bug. A bug in one dependency or one user field is enough. Use the cookie to limit the loss.

Check your token storage

A VibeEval scan loads your deployed app, checks how the session token is stored and sent, and reports cookies that lack the HttpOnly and Secure flags.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →