TK
Back to all posts

The part of a URL your server never sees: shareable links that upload nothing

Everything after the # in a URL stays in the browser. It is never in the request line, never in the Referer header, never in your access logs. Here is how TaskKit packs a whole document into the fragment so a share link decodes on the recipient's machine and the data never touches a server, plus the one hard ceiling on the trick.

Published

Here is a problem that sounds trivial and isn’t: let someone share the exact contents of a browser tool with a teammate, by link, without the contents ever touching a server.

The two obvious answers both fail the “without a server” part. You can POST the document to a backend, store it, and hand back a short URL. That works, it survives any payload size, and it means the document now lives on someone’s disk and in someone’s logs. Or you can take a screenshot, which uploads nothing but also can’t be edited, searched, or re-run.

There’s a third option that a surprising number of developers have never deliberately used, even though they look at it every day: the part of the URL after the #. The fragment. It has a property the rest of the URL doesn’t, and that property is the whole game.

The one part of a URL the server never sees

When your browser requests https://taskkit.net/dev/json-formatter#eyJpIjoi..., it opens a connection and sends a request line that looks like this:

GET /dev/json-formatter HTTP/1.1
Host: taskkit.net

The fragment is gone. The browser strips everything from the # onward before it builds the request. The server is never told it existed. This isn’t a TaskKit behavior or a framework behavior, it’s in the definition of a URL (RFC 3986): the fragment identifies a sub-resource within the retrieved document, so it’s resolved by the client after the response arrives. The server’s job ends at the path and query. The fragment was never its business.

It goes further than the request line. The fragment is also stripped from the Referer header on any outbound navigation or sub-request, unconditionally, regardless of your Referrer-Policy. So a third-party script, an image CDN, an analytics beacon: none of them receive it through the normal request plumbing either. It does not appear in your access logs, your CDN logs, or any server-side analytics that parse the request URL, because the string those systems see never contained it.

Be precise about the claim, though, because there’s a real boundary. “The server never sees it” is true. “Nobody can ever see it” is not. The fragment is right there in the address bar, it’s saved in the user’s browser history, and any JavaScript running on the page can read location.hash and choose to send it somewhere. The guarantee is specifically about the wire and the server, and it holds only as long as the page you’re on doesn’t ship code that reads the hash and phones home. On a page that loads no third-party analytics and no ad SDK, that’s a guarantee you can actually reason about. On a page that loads ten trackers, the hash is one location.hash read away from all of them. The mechanism is only as private as the page hosting it.

For a tool whose entire pitch is “your data stays on your device,” the fragment is the natural place to put shareable state.

What we put in there

TaskKit’s JSON formatter encodes the input plus a few view settings into the fragment. The state is small and structured, so the payload is JSON, base64url-encoded:

export function encodePermalink(state: JsonPermalinkState): string {
  const payload = {
    i: state.input,                              // the document
    a: state.action === "minify" ? "m" : "f",    // format or minify
    n: state.indent,                             // 2 | 4 | "tab"
    s: state.sortKeys ? 1 : 0
  };
  return base64UrlEncode(JSON.stringify(payload));
}

Two encoding choices in that last line are worth pulling apart, because the obvious version of each is subtly wrong.

base64url, not base64. Standard base64 uses +, /, and =. All three are hazards in a URL. + decodes to a space in query strings, / reads as a path separator, and = is reserved. Worse, none of them survive a round trip through the places share links actually go: chat clients, email quoting, Markdown auto-linkers. base64url swaps + and / for - and _ and drops the = padding, which gives you a token that’s safe to drop straight after a # and survives being pasted into Slack:

function base64UrlEncode(input: string): string {
  const bytes = new TextEncoder().encode(input);
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}

TextEncoder, not bare btoa. btoa operates on a “binary string” and throws the moment it meets a code point above 0xFF. Paste a JSON document with an em dash, a Chinese character, or an emoji into btoa directly and it dies with InvalidCharacterError. Running the string through TextEncoder().encode() first turns it into UTF-8 bytes, and those are always in range for btoa. The decode side mirrors it with TextDecoder. If you’ve ever seen a “share this state in the URL” feature break on non-English input, this is almost always the missing step.

Reading it back without trapping the user

Encoding is the easy half. The half that takes judgment is what happens when someone opens the link. TaskKit’s formatter hydrates from the hash on first mount, then immediately scrubs it:

useEffect(() => {
  const fromHash = decodePermalink(window.location.hash.slice(1));
  if (fromHash) {
    setInput(fromHash.input);
    setAction(fromHash.action);
    setIndent(fromHash.indent);
    setSortKeys(fromHash.sortKeys);
    history.replaceState(null, "", window.location.pathname);  // <- the important line
    return;
  }
  // …fall back to ?json= for JSONLint-style links
}, []);

That replaceState call is the part people forget. Without it, the hash sticks in the address bar. The user opens your shared link, sees the document, starts editing it, then hits refresh, and the page reloads from the original hash, silently throwing away every edit they just made. The shared link has become a trap that resets their work on every reload. Scrubbing the hash right after hydration makes the link a one-time seed: it populates the editor once, then gets out of the way so the tab behaves like a normal local session. replaceState (not pushState) means it also doesn’t add a history entry, so the back button isn’t polluted either.

Note the priority order too: the hash wins over the ?json= query parameter. The query-param path exists only so links from the JSONLint ecosystem migrate cleanly. Which brings up the thing we deliberately don’t do.

The deliberate non-feature: ?url=

A lot of JSON tools accept a ?url=https://… parameter: give it a link, it fetches the document and loads it. TaskKit recognizes that parameter and refuses to act on it. The code reads the query string, sees url, and does nothing except optionally hint the user to paste the contents themselves.

The reason is the whole posture of the site. Auto-fetching a URL means the page makes an outbound request to a server the user named, from the user’s browser, carrying the user’s IP and whatever that server decides to log. For a tool that promises nothing leaves your device, quietly making network calls on the user’s behalf because a query parameter told it to is exactly the betrayal you’re trying to avoid. So the fragment (local, never sent) is in. The url= fetch (outbound, server-touching) is out. The asymmetry is the product.

A decoded hash is hostile input

Here’s a framing that’s easy to miss: a permalink is user-supplied input from a potentially hostile sender. Anyone can hand-craft a fragment and send you the link. So the decode path treats it as untrusted by construction:

export function decodePermalink(hash: string): JsonPermalinkState | null {
  if (hash === "") return null;
  try {
    const obj = JSON.parse(base64UrlDecode(hash));
    return {
      input: typeof obj.i === "string" ? obj.i : "",
      action: obj.a === "m" ? "minify" : "format",
      indent: obj.n === 4 ? 4 : obj.n === "tab" ? "tab" : 2,
      sortKeys: obj.s === 1
    };
  } catch {
    return null;        // garbage in, clean null out
  }
}

Every field is type-checked and coerced to a known-good value, not trusted as-is. Malformed base64, non-JSON, JSON-of-the-wrong-shape: all of it falls through the try/catch to null, and the tool just opens empty. And because the decoded input flows into a controlled React text state (not innerHTML, not eval, not a template), there’s no injection surface even if the string is malicious. The worst a crafted link can do is pre-fill the editor with text, which is the entire feature. That’s the bar for anything you decode from a URL: assume the sender is an attacker, validate shapes, and make sure the value can only land somewhere inert.

The ceiling: URL length

This technique has one hard limit, and it’s worth stating plainly rather than discovering it in production. URLs are not infinitely long. Browsers, address bars, proxies, and the chat apps people paste links into all have practical caps, and base64 makes the problem worse: it inflates the payload by roughly a third before the JSON wrapper is even counted. So TaskKit caps it:

// 16 KB raw input gives ~22 KB encoded URL: safely within everyone's tolerance.
export const PERMALINK_MAX_INPUT_BYTES = 16_000;

export function canEncodePermalink(state: JsonPermalinkState): boolean {
  return state.input.length <= PERMALINK_MAX_INPUT_BYTES;
}

Above 16 KB of input, the share button disables itself and the tooltip explains why, instead of silently generating a link that’s truncated or rejected by the recipient’s client. A 5 MB JSON document is simply not shareable this way, and pretending otherwise produces broken links. This is the precise point where the server-side “save and get a short link” approach wins: it survives any size, because the payload rides in a request body instead of a URL. The trade is the one this whole post is about. The short link is convenient and the document left the device; the fragment link keeps the document local and can’t carry a large one. Different tools, different defaults.

If you wanted to push the ceiling up without giving up the local guarantee, the lever is compression, not a bigger cap. The browser ships CompressionStream now, so you could gzip the JSON before base64url-encoding it and inflate on decode. Highly repetitive documents (which is most JSON) shrink a lot, so the effective input limit climbs while the URL stays the same length. The current code doesn’t do this; it’s plain base64url of JSON. Compression is the obvious next step if the 16 KB ceiling ever starts to pinch, and it changes nothing about the privacy property, because the bytes still never leave the browser.

When the fragment is the wrong tool

Three cases where you reach for something else:

  • Large payloads. Past a few tens of KB, even compressed, you’re fighting URL limits. Use a server-side store and accept that the data left the device, or don’t share by link at all.
  • Truly private from everyone, not just the server. The fragment lives in history and is readable by any script on the page. If your threat model includes the recipient’s browser extensions or a shared machine’s history, a link is the wrong container.
  • Durable links. A fragment encodes your current state schema. Change the shape of what you encode (rename a field, drop a setting) and old links may decode into something slightly off. The type-checked decode keeps that safe rather than broken, but “safe” can still mean “an old link loads without your new setting.” Version the payload if links need to outlive your schema.

For everything else, sharing the exact state of a tool with a colleague, no upload, no account, no expiry, the fragment is a clipboard the server is structurally incapable of reading.

Why this lives in a browser tool

The reason TaskKit shares by fragment instead of by server-stored short link is the same reason its Markdown to PDF export runs in the browser and its regex tester kills its own worker instead of shipping patterns to a backend. When you send someone a TaskKit link, the document is in the link, and the link is decoded on their machine. The server that served the page is never in the loop and has nothing to log. That’s not a privacy policy you have to trust. It’s a consequence of which half of the URL the data lives in.

You can try it on the JSON formatter (paste something, hit copy-link, look at what’s after the #) and the regex tester, which encodes the pattern, flags, and test string the same way.