DEV Community

pasindu Ishan
pasindu Ishan

Posted on

My JSON formatter's “Share” button was leaking pasted JSON to analytics

I run a collection of browser-based developer tools at JSON Dev Tools, including a JSON formatter and JWT decoder. The main idea is simple: the data you paste into the tools should stay in your browser.

While preparing the site for a review, I went back through some of my older code and found a problem in my JSON formatter's Share feature.

The formatter wasn't sending the JSON to an API.

The processing itself was entirely client-side.

But the share link was putting the compressed JSON into the URL query string, which meant the data could end up somewhere I didn't intend.

Here's what happened and how I fixed it.

The bug

The formatter has a Share button that creates a URL containing the formatted JSON.

The JSON is compressed using lz-string:

const compressed = LZString.compressToEncodedURIComponent(text);

const url =
  location.origin +
  location.pathname +
  '?j=' +
  compressed;
Enter fullscreen mode Exit fullscreen mode

At first glance, this seems reasonable. The compressed data is small enough to put in a URL, and the page can reconstruct the JSON when someone opens the link.

The problem is the ?j=.

That puts the data in the URL query string.

1. The query string is sent with the request

When someone opens a URL like:

https://jsondevtools.org/json-formatter?j=...
Enter fullscreen mode Exit fullscreen mode

the query string is part of the HTTP request.

My site is static and hosted on GitHub Pages, so I don't operate a server that processes these requests myself. But the request still goes to the hosting infrastructure.

That means the data isn't necessarily staying exclusively inside the browser anymore.

2. Analytics can see the URL

This was the part I hadn't considered carefully enough.

The site uses Google Analytics 4.

GA4 can collect the page URL, including query parameters, through the page_location parameter.

So if someone opened a share URL containing compressed JSON, the JSON could be included in the analytics data as part of the page URL.

The JSON formatter wasn't intentionally sending the user's data to analytics.

The URL itself was doing it.

That's an important distinction, because it meant the bug was easy to miss when looking only at the formatter's data-processing code.

The fix: use a URL fragment

The solution was to move the compressed data from the query string into the URL fragment.

Instead of:

/json-formatter?j=compressed-data
Enter fullscreen mode Exit fullscreen mode

the share link now looks like:

/json-formatter#j=compressed-data
Enter fullscreen mode Exit fullscreen mode

The code becomes:

const compressed = LZString.compressToEncodedURIComponent(text);

const url =
  location.origin +
  location.pathname +
  '#j=' +
  compressed;
Enter fullscreen mode Exit fullscreen mode

The important difference is that the fragment (#...) is handled by the browser and is not sent as part of the HTTP request.

The page can read it locally:

function getSharedPayload() {
  if (location.hash.indexOf('#j=') === 0) {
    return location.hash.slice(3);
  }

  return new URLSearchParams(location.search).get('j');
}
Enter fullscreen mode Exit fullscreen mode

This also gave me a way to keep old share links working.

Backwards compatibility

There are already links using the old format.

I didn't want to break them just because the implementation changed.

So the loader supports both:

?j=...
Enter fullscreen mode Exit fullscreen mode

and:

#j=...
Enter fullscreen mode Exit fullscreen mode

For an old link, the JSON is loaded from the legacy query parameter and then the URL is cleaned up with history.replaceState.

Fixing the analytics side

Moving the payload to the fragment protects the new share links from being sent in the HTTP request, but I still needed to consider the legacy ?j= URLs.

The analytics configuration runs in the <head> of the page, before the formatter's JavaScript has a chance to remove the old parameter.

So simply doing this later:

history.replaceState(...)
Enter fullscreen mode Exit fullscreen mode

isn't enough.

Instead, I explicitly construct the URL that should be sent to Google Analytics:

const params = new URLSearchParams(location.search);

params.delete('j');

const qs = params.toString();

const pageLocation =
  location.origin +
  location.pathname +
  (qs ? '?' + qs : '');

gtag('config', 'G-XXXXXXX', {
  page_location: pageLocation
});
Enter fullscreen mode Exit fullscreen mode

Now the j parameter isn't included in the page location sent to analytics.

Testing the fix

I didn't want to stop at reading the code.

I tested the behavior in a browser:

  1. Generate a new share link and verify that the JSON is stored after #j=.
  2. Open an old ?j=... share link and verify that the JSON still loads.
  3. Verify that the legacy j parameter is removed from the address bar.
  4. Verify that unrelated query parameters remain intact.
  5. Run the analytics code against a fake URL containing a j parameter.
  6. Confirm that the page_location passed to gtag doesn't contain the JSON payload.

What about old links?

There is still an unavoidable limitation.

Some old share links using the ?j= format may already exist.

Opening one of those links still means the query string is sent with the initial request before the page has an opportunity to remove it.

I can't retroactively change those URLs.

I documented this limitation in the site's privacy policy rather than pretending that the new implementation somehow fixes historical links.

New share links now use the fragment-based format.

What I learned

The biggest lesson for me was that "the data is processed in the browser" isn't the same thing as "the data never leaves the browser."

You need to look at the entire feature.

For a privacy-focused browser tool, that includes:

  • Share links
  • Query parameters
  • URL fragments
  • Analytics
  • Error reporting
  • Export functionality
  • External APIs
  • Third-party scripts

In my case, the JSON processing was client-side and didn't need to change.

The problem was the mechanism I used to make the processed data shareable.

A few practical rules I'm taking away

1. Be careful about putting user data in query parameters.

Query parameters are part of the HTTP request and can also appear in analytics, logs, referrers, browser history, and other places.

2. Consider URL fragments for client-side-only data.

Fragments aren't included in the HTTP request, which makes them useful for data that only needs to be interpreted by client-side JavaScript.

3. Don't forget analytics.

Analytics libraries operate independently from your application's data-processing logic.

If user-controlled data can appear in the URL, check exactly what your analytics implementation sends.

4. Test the actual browser behavior.

Reading the code wasn't enough.

I needed to verify the generated URL, legacy-link behavior, history replacement, and the actual value passed to analytics.

A privacy promise is only as good as the least-obvious place where data can escape.


Disclosure: I build and maintain the tools mentioned in this post at jsondevtools.org. Feedback and corrections are welcome.

Top comments (0)