URL fragment (`#`) gotchas: server invisibility, SPA routing, OAuth implicit flow leaks, and scroll restoration
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.