←Research
Researchvulnerability7 min read

checkcle PR #224 moved PocketBase JWTs out of localStorage

operacle/checkcle persisted PocketBase authentication JWTs in localStorage, making token theft trivial after any same-origin script execution. PR #224 replaces local persistence with an in-memory auth store.

operacle/checkcle persisted PocketBase authentication JWTs in browser localStorage under the key pocketbase_auth. I identified the issue as CWE-922, submitted PR #224 and the fix was applied on 4 May 2026. The change replaces PocketBase's persistent browser auth store with an in-memory BaseAuthStore, removes the custom persistence handler and deletes any stale token that earlier builds had already written.

The bug is not exotic. That is the point. A single-page app stored a bearer token in a location where every same-origin script can read it. If any XSS lands anywhere in the application, the attacker does not merely get code execution for the lifetime of the victim's tab. They get a portable authentication token that can be replayed from another machine until it expires or is revoked.

Where the token was stored

The vulnerable code lived in application/src/lib/pocketbase.ts, the file that creates the shared PocketBase client for the SPA. The application initialised PocketBase with the default browser behaviour:

export const pb = new PocketBase(getCurrentEndpoint());

Below that, the old startup block began around line 40. It checked whether the browser had an existing pocketbase_auth entry. If it did, the code parsed the JSON and saved the token and model back into pb.authStore:

const storedAuthData = localStorage.getItem('pocketbase_auth');
if (storedAuthData) {
  const parsedData = JSON.parse(storedAuthData);
  pb.authStore.save(parsedData.token, parsedData.model);
}

The same file then subscribed to auth store changes. In the old file, lines 52 through 62 wrote both the JWT and user model into localStorage whenever PocketBase considered the auth store valid:

pb.authStore.onChange(() => {
  if (pb.authStore.isValid) {
    localStorage.setItem('pocketbase_auth', JSON.stringify({
      token: pb.authStore.token,
      model: pb.authStore.model
    }));
  } else {
    localStorage.removeItem('pocketbase_auth');
  }
});

That is the vulnerable code path. Authentication state moved from PocketBase into a browser persistence layer designed for convenience rather than secrecy.

The exploitable data flow

The data flow is short enough to fit in four steps:

  1. A user authenticates and pb.authStore receives a JWT.
  2. The onChange handler serialises { token, model } and writes it to localStorage as pocketbase_auth.
  3. Any JavaScript executing on the same origin can call localStorage.getItem('pocketbase_auth').
  4. The token can be sent to an attacker and replayed from outside the victim's browser.

That third step is the important one. localStorage is not protected from application JavaScript. It has no HttpOnly equivalent. The OWASP HTML5 Security Cheat Sheet explicitly warns against storing session identifiers in local storage because a single XSS can retrieve them.

The usual counterargument is that this requires XSS first. That is true but incomplete. XSS without token persistence is often a live, in-tab compromise. It ends when the page closes, the payload is removed or the victim navigates away. XSS plus a stored bearer token becomes credential theft. The attacker can authenticate from their own environment after the original script stops running.

There was no compensating control in the application that changed that assessment. The adversarial review found no strict Content Security Policy that would make inline script execution materially harder, no Trusted Types policy and no framework-level token isolation. React and Vite do not make localStorage private. Same-origin JavaScript can read it because that is the API contract.

Why CWE-922 fits

CWE-922 covers insecure storage of sensitive information. In this case the sensitive value is a PocketBase JWT, and the insecure storage location is browser localStorage. The weakness is not that the token exists on the client. Browser applications need some client-side authentication state. The weakness is that checkcle chose a persistence mechanism readable by every script in the origin and rehydrated authentication from it on each page load.

This pattern has produced real CVEs before. CVE-2020-27839 describes Ceph Dashboard storing a JWT in browser localStorage, allowing token theft through XSS. CVE-2021-3509 followed after Ceph moved the JWT into an HttpOnly cookie but retained an XSS path that still affected the dashboard. The lesson is not that cookies magically solve every client-side flaw. It is that readable bearer tokens make any script injection more durable and more useful to an attacker.

What PR #224 changed

The fix changes the PocketBase import and the client constructor:

import PocketBase, { BaseAuthStore } from 'pocketbase';
 
export const pb = new PocketBase(getCurrentEndpoint(), new BaseAuthStore());

BaseAuthStore keeps authentication state in memory. It exposes the same API surface used elsewhere in the app, including isValid, token, model and clear, so the rest of the SPA does not need to know whether the token came from a persistent browser store or a memory-only store.

The explicit persistence code is removed entirely. The startup localStorage.getItem() and pb.authStore.save() block is gone. The authStore.onChange() handler that wrote { token, model } to disk is gone. In its place, the browser block now does one thing:

if (typeof window !== 'undefined') {
  localStorage.removeItem('pocketbase_auth');
}

That cleanup matters. Without it, users who had already run a vulnerable build could keep a stale pocketbase_auth entry in the browser indefinitely. The application would no longer use it, but the secret would still be sitting in script-readable storage waiting for a future XSS to collect it. Removing it on page load makes the fix retroactive for previously persisted entries.

The diff is intentionally narrow: six insertions and twenty-six deletions in one file. There are no backend changes, schema migrations or API changes.

The tradeoff is real

The security improvement costs session continuity. With an in-memory store, a full page reload clears the token and the user needs to authenticate again. That is less convenient than surviving reloads through localStorage.

For checkcle, the tradeoff is sensible. The project is an operational monitoring tool. Its interface is expected to sit near infrastructure data, service health information and administrative workflows. A reload prompt is annoying. A stolen bearer token for an infrastructure monitoring interface is worse.

If durable browser sessions are needed later, the safer design is not to return to localStorage. It is a server-managed session using a cookie marked HttpOnly; Secure; SameSite, with server-side expiry and revocation semantics. That design still needs CSRF handling and careful session management, but it removes the direct JavaScript read primitive that made this finding exploitable.

Where this fits in AI and agent tooling

checkcle is not an LLM agent framework, but the pattern overlaps with the AI tooling security work I have been writing about throughout 2026. Modern AI and automation interfaces accumulate credentials: API keys, access tokens, service accounts, MCP configuration files and backend sessions. The common failure mode is storing those credentials where the most convenient component can reach them, then discovering that attackers can reach them too.

In the gptme case study, API keys were passed to Docker on the command line where local users could read them through the process table. In the MCP attack surface analysis, tool ecosystems exposed credentials through prompt-influenced actions, poisoned descriptions and weak trust boundaries. In full-stack-ai-agent-template, a debugging feature that stored webhook responses turned SSRF into response exfiltration.

The shared theme is not a single CWE. It is credential adjacency. These applications sit close to secrets because that is what makes them useful. They call APIs, manage infrastructure, query services and automate work that used to require a human operator. Every storage decision around those credentials needs to assume that one adjacent component will eventually fail.

localStorage is rarely the right answer under that assumption. It optimises for easy persistence across reloads. It does not optimise for containment after XSS, browser extension compromise or temporary local access. In software that touches operational data, that is the wrong trade unless the risk has been accepted explicitly.

The uncomfortable part is how often this class of bug survives because it looks like normal frontend plumbing. A token is saved so the user stays logged in. A startup block restores it. A change handler keeps it current. The code reads as a feature until the threat model includes hostile JavaScript, then it becomes a credential export API with a friendly name.

Newsletter

One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.