After a guard redirects to sign-in, how should the app remember and safely restore the page the user wanted?
answer
- the returned target is untrusted input
- one leading slash, never two
- a path and query, not a URL
- replace the entry, do not push
- re-check access before you land
basics
~20 sCapture the intended internal target when the guard redirects, carry it on the sign-in URL or the history entry's state, and treat it as untrusted: accept only a path on this origin, else a default.
solid answer
~50 sThe guard already knows the attempted target, so it records it — path plus query, and the fragment when the screen uses one — and hands it to the sign-in route. Three carriers: a query parameter on the sign-in URL survives reloads and sharing but is visible and editable; the history entry's state is invisible but absent when sign-in is opened directly; an in-memory value is simplest and lost on reload. Whatever the carrier, the value is untrusted on the way back: accept it only as a path on this origin — one leading slash, not two, no scheme, origin compared after parsing against the app's own origin — and otherwise fall back to a default landing route. Replace the history entry rather than pushing it, and re-run the access check on the restored target so a still-forbidden destination lands on a default instead of looping.
code
pseudocode · 9 linesfunction safeReturnTarget(raw, fallback = "/"):
if raw is empty: return fallback
if raw does not start with "/": return fallback # absolute or scheme-bearing
if raw starts with "//": return fallback # protocol-relative: other origin
if raw starts with "/\": return fallback # leniently parsed separator
parsed = parse raw against this app's own origin
if parsed.origin is not this app's origin: return fallback
if parsed.path is the sign-in route: return fallback # would loop
return parsed.path + parsed.query + parsed.fragmentgo deeper
Know that the guard records where the user was going, usually on the sign-in URL, and that the app sends them there after signing in instead of to a fixed home screen.
Explain the carriers and their tradeoffs, and why the returned value must be reduced to an internal path with a default fallback rather than used as given.
Cover the operational details: replace rather than push, re-check access on the restored target, handle a stale or deleted destination, and keep concurrent tabs independent.
Make one helper the only way a destination is restored anywhere in the app, so no screen re-implements the check, and decide deliberately what a failed restore looks like to the user.
## What has to be captured A guard that redirects to sign-in is the only code that still knows where the user was going, so capture happens there. "Where" means more than a path: a report screen reached with filters in the query is a different destination from the same screen with none, and a screen that scrolls to an anchor needs the fragment. Capture the **internal target** — path, query, and fragment when it is meaningful — as a single string, and never the whole absolute URL. Storing an absolute URL is what turns a convenience feature into a way to send users off-site. ## Where to carry it | Carrier | Survives a reload | Visible to the user | Present when sign-in is opened directly | Notes | |---|---|---|---|---| | **Query parameter on the sign-in URL** | yes | yes | yes, whatever was typed | shareable, and fully attacker-controlled | | **State attached to the history entry** | yes | no | no | not shareable; needs a fallback when missing | | **In-memory value in the app** | no | no | no | simplest; loses the target on reload | | **Persistent client storage** | yes | no | yes | leaks between tabs and outlives the flow; avoid | The query parameter is the usual choice because a sign-in flow that bounces through a reload or a third-party step keeps working. The cost of that choice is that the value is part of a URL anyone can craft and send, so validation is not optional. Persistent storage looks attractive and behaves badly: two tabs share one slot, so signing in on one tab sends the other's destination to the wrong place, and a stale value resurfaces days later. ## Treat the value as untrusted The rule is not "sanitise the string" but **reduce it to a path on this origin or throw it away**: - Reject anything that does not begin with exactly one `/`. A value beginning with `//` is protocol-relative and points at another origin even though it looks like a path. - Reject anything containing a scheme, and remember that backslashes and percent-encoded separators are parsed leniently by some code paths. - Parse the candidate against the app's own origin and compare the parsed origin with it; keep only the path, query and fragment of the result. - Fall back to a **default landing route** whenever the value is missing, malformed, or fails any check — silently, since a user who edited it does not need a message. - Do not try to allow-list hostnames. A prefix check on a host is defeated by a lookalike, and the app does not need cross-origin destinations in the first place. ## History hygiene 1. The guard's redirect to sign-in should **replace** the pending entry rather than push one. If it pushes, the guarded URL sits one step back and Back walks into the guard again, which bounces the user forward: the classic sign-in ping-pong. 2. On success, navigate to the restored target with a **replacement** as well, so the sign-in screen does not remain in history behind the destination. 3. Never restore a target that is the sign-in route itself, or a route whose only job is to redirect — that is a two-step loop. ## Re-check before you land Signing in successfully does not mean the captured target is now reachable: the account may lack the role, the record may have been deleted, or the workspace may have changed. Route the restore through the **same guard chain** as any navigation rather than rendering the target directly, and treat a second refusal as final — land on a default screen and explain it. An app that keeps re-attempting a forbidden target after each sign-in produces an infinite bounce that looks like a broken login. ## Failure modes worth naming - **The captured target is stale.** A sign-in that takes minutes can outlive the destination; a not-found or forbidden result must be a designed screen, not a blank one. - **Several tabs sign in at once.** Per-navigation carriers keep each tab's intent separate; a shared persistent slot does not. - **The intent is captured under the unknown session state.** The guard should not redirect at all until the session resolves, or it will record whatever route the user was passing through. - **The fragment is dropped.** Harmless for most screens, visible on a documentation page that relies on anchors — decide deliberately rather than by accident. ## What interviewers listen for - That you call the returned value untrusted input and reduce it to an internal path, without being asked about attacks. - That you can name the tradeoffs of the carrier you chose rather than only the mechanism. - That replace-versus-push and the loop it prevents come up unprompted. - That you re-check access on the restored target instead of assuming sign-in settled it.
- Why is checking that the value starts with a slash not enough?Because a value starting with two slashes is protocol-relative: it looks like a path and resolves to a different origin. Lenient parsing of backslashes and encoded separators can produce the same effect. Parse the candidate against the app's own origin, compare origins, and keep only the path, query and fragment.
- What should happen if the restored target is still forbidden after sign-in?Run the restore through the normal guard chain and treat a second refusal as final: land the user on a default screen with an explanation. Retrying the captured target after every sign-in turns one missing permission into an endless bounce that users report as a broken login.
- How would you keep the destination out of the address bar?Attach it to the history entry's state instead of the sign-in URL. It survives a reload and is invisible, but it is absent when someone opens or shares the sign-in URL directly, so the flow still needs a default. An in-memory value is simpler and does not survive a reload at all.
saying these in an interview costs you the question
- Stores the whole absolute URL and navigates straight to it
- Trusts the parameter because the guard is what wrote it
- Checks only that the value begins with a slash
- Pushes sign-in, leaving the guarded URL one step back
- Skips the access check on the restored destination
- Keeps the pending destination in shared persistent storage