URL fragment (`#`) gotchas: server invisibility, SPA routing, OAuth implicit flow leaks, and scroll restoration

4 min read

URL fragments — everything after # — are not sent to servers in HTTP requests. This is the spec, RFC 3986 §3.5. The cascade of consequences trips up real systems: “I can’t find #hash in the access log,” “my SPA’s hash routing is invisible to search,” “OAuth implicit flow leaked an access token.” This article walks through the fragment-specific traps.

Baseline: only the client sees the fragment

https://example.com/page?q=hello#section-2
└─────────────────────────────────┘└────────┘
            sent to server         client only

The browser builds an HTTP request from Host + path + query, but everything after # stays in the browser. The fragment exists for:

  • Scrolling to in-page anchors after navigation completes.
  • Holding state for JavaScript without reloading the page.

Consequence: fragments never appear in server logs

Want to investigate which #hash URLs users are hitting via Apache access logs? Impossible — the server has never seen the fragment. Use client-side instrumentation (GA, RUM).

Trap 1: OAuth implicit flow access-token leakage

OAuth 2.0 implicit flow (deprecated but still in the wild) returns the access token in the fragment:

https://app.example.com/callback#access_token=eyJhbGc...&token_type=Bearer

The intent: “fragments aren’t sent to servers, so the OAuth provider’s logs don’t see the token.” But:

  • Browser extensions can read fragments.
  • Browser history records them.
  • Older Referer behavior sometimes included the fragment, leaking the token to third-party resources (analytics tags, ad pixels).

OAuth 2.1 deprecates implicit flow because of these. The replacement is Authorization Code + PKCE, where the token is fetched server-to-server.

Trap 2: SPA hash routing

“Hashbang (#!)” / “hash routing” is the classic SPA approach:

https://app.example.com/#/dashboard
https://app.example.com/#!/users/123

Pros:

  • No special server config — every URL hits the same index.html.
  • Works on plain static hosting.

Cons:

  • Search engines don’t index it well (Google retired the hashbang crawl spec in 2015).
  • Server-side analytics see only the path, missing routing data.
  • # causes URL-share friction in chat apps and email.

Modern alternative: History API routing

React Router, Vue Router, SvelteKit all default to pushState / popstate:

https://app.example.com/dashboard
https://app.example.com/users/123

Requires the host to fall back unknown paths to index.html. Cloudflare Pages, Netlify, Vercel, and SvelteKit’s adapter-static all support this out of the box.

Trap 3: scroll restoration and #

Browsers auto-scroll to an element with the id matching the fragment. Classic anchor jump.

:target pseudo-class

CSS can style the element targeted by the current fragment:

:target {
	background: yellow;
	scroll-margin-top: 80px; /* offset for sticky header */
}

scroll-margin-top is the answer to “fixed header hides the anchor target after a jump.”

Combined with the History API

history.pushState() does NOT scroll, even if the URL change includes a #. Only direct location.hash assignment triggers an anchor jump:

location.hash = 'section-2'; // scrolls
history.pushState(null, '', '#section-2'); // does NOT scroll

SPAs that want “update URL fragment without scrolling” use pushState and call scrollIntoView themselves.

Trap 4: cookies’ Path doesn’t include #

Cookie Path is a server-side concept, so it can’t reference fragments:

Set-Cookie: foo=bar; Path=/dashboard

Sent on all requests under /dashboard. /dashboard#tab=settings matches because #tab=settings was never sent. You can’t have “different cookies per fragment.” Use query parameters or different paths instead.

Trap 5: characters that need encoding in fragments

Per RFC 3986 §3.5, only pchar / "/" / "?" ASCII characters are allowed in fragments without encoding. Non-ASCII (CJK, etc.) must be percent-encoded:

https://example.com/page#日本語        ← strictly invalid
https://example.com/page#%E6%97%A5%E6%9C%AC%E8%AA%9E   ← valid

Browsers usually auto-encode, but server-generated URLs and email links need explicit encoding. Use encodeURIComponent():

const url = `/page#${encodeURIComponent(value)}`;

Trap 6: fragment carry-over on HTTP redirects

When HTTP/1.1 302 Found returns a Location header for a redirect, browser behavior varies:

  • Target has no fragment: most browsers carry over the original URL’s fragment.
  • Target has a fragment: that one wins.

Side-effect: redirecting /login#section-2 to /dashboard may end up at /dashboard#section-2, which the user didn’t expect.

Spec text

RFC 7231 §7.1.2: “The user agent SHOULD use the [original] fragment if the Location does not contain one.” To clear the fragment explicitly, include an empty fragment in the redirect target: https://example.com/dashboard# — most browsers honor that as “no fragment.”

Summary

The URL fragment is ”information only the client knows; the server can’t see it.” Everything else cascades from that: no log presence, OAuth leakage, SPA routing trade-offs, cookie path independence, redirect carry-over. When designing a URL scheme, start by asking ”does the server need this?” — the answer immediately decides between fragment and query string.

For percent-encoding behavior in URL contexts, the URL encode tool shows the difference between encodeURI and encodeURIComponent outputs.