URLs are how your app references itself - every redirect, every email link,
every Inertia <Link> href, every signed download has to come from
somewhere. Hard-coding paths makes refactors painful and route renames
unsafe. Suprnova ships a small url:: namespace and a sibling
route() helper that take a name plus parameters and give you back a
string, with percent-encoding handled, signature minting available, and
verification that matches Laravel's wire format byte-for-byte.
This chapter is the reference for the URL-generation surface. The Routing chapter covers how to declare routes and name them; this one covers what you do with those names afterwards.
use ;
// Lookup by name → URL
let profile = route.unwrap;
// "/users/42"
// Absolute URL against APP_URL
let absolute = to;
// "https://app.test/dashboard"
// Signed link for password reset
let link = signed_route?;
// "/password/reset/xyz?signature=ab12..."
// Verify on the inbound request
if has_valid_signature?
Everything in this chapter is re-exported under suprnova::url::* and
suprnova::route so consumer code never has to reach into the routing
module directly.
Named routes
A name is a string label attached to a route at registration time. Once a
name exists, route(name, params) resolves it back to a URL pattern and
substitutes the parameters. Names live in a single process-global
registry - there is one name → path table per running binary, not one
per Router.
use ;
routes!
The .name(...) call registers "users.show" → "/users/{id}". From
that point on, anywhere in the process can resolve the name:
use route;
let url = route;
// Some("/users/42")
let missing = route;
// None
Re-registering the same (name, path) pair is idempotent - useful when
route registration runs more than once during boot. Registering a name
under a different path panics; that collision is a security-shaped
bug because helpers like Redirect::route would silently target
whichever side won the race.
The lookup helpers
| Function | Returns | When the route is missing |
|---|---|---|
route(name, params) |
Option<String> |
None |
route_with_params(name, params_map) |
Option<String> |
None |
try_route(name, params) |
Result<String, RouteUrlError> |
Err(NameNotFound) |
try_route_with_params(name, params_map) |
Result<String, RouteUrlError> |
Err(NameNotFound) |
The lenient route / route_with_params pair leaves any unfilled
{placeholder} segment verbatim in the output - fine for debug logs,
unsafe to ship to a browser. The strict try_route / try_route_with_params
pair returns RouteUrlError::MissingParams { name, missing } listing the
unfilled placeholders so the caller can fail loudly instead of redirecting
a user to /users/{id}.
use ;
match try_route
Redirect::route uses try_route_with_params under the hood for exactly
this reason - a redirect with a raw {id} in the Location header would
be worse than failing.
Percent-encoding is automatic
Parameter values are encoded per RFC 3986 path-segment rules before they
are substituted in. That covers the gen-delims and sub-delims
(/ ? # [ ] @ ! $ & ' ( ) * + , ; =), control characters, space, and
% itself. Unreserved characters (A-Z a-z 0-9 - _ . ~) pass through
unchanged.
use route;
// A slug containing a slash is contained in one segment:
route;
// Some("/posts/hello%2Fworld")
// Path traversal attempts can't escape the segment:
route;
// Some("/users/..%2F..%2Fetc%2Fpasswd")
// Real Unicode passes through untouched:
route;
// Some("/users/user-%C3%A9-42")
The matching side preserves this round-trip - a request to
/posts/hello%2Fworld matches the /posts/{slug} route and a handler
reading req.param("slug") sees "hello/world", decoded. Encode at the
boundary, decode at the boundary; never see the raw bytes in handler code.
Reverse lookup
When you have a matched route pattern and want the registered name -
e.g. for logging or for Request::route_is("users.show") checks - use
route_name_for_pattern:
use route_name_for_pattern;
let name = route_name_for_pattern;
// Some("users.show")
This is an O(n) scan over the name registry. n is the number of
registered names; even at four-digit route counts the cost is negligible
compared to the surrounding request lifecycle. The function is exposed
for tooling and middleware - Request::route_is already calls it for
you when you compare against a named route in a handler.
Absolute URLs
For everything else - building emails, sharing URLs, sending Open Graph
metadata - you want an absolute URL with the right scheme and host.
url::to joins a path to APP_URL:
use url;
// In env: APP_URL=https://app.example.com
let url = to;
// "https://app.example.com/about"
// Already-absolute URLs pass through unchanged:
let cdn = to;
// "https://cdn.example/asset.js"
let proto_relative = to;
// "//cdn.example/asset.js"
The host, scheme, and port all come from APP_URL. If APP_URL is
http://localhost:8765, then url::to("/foo") yields
"http://localhost:8765/foo". The trailing slash on APP_URL is
normalised away so you never end up with https://host//path.
Forcing HTTPS
url::secure(path) builds the same absolute URL but upgrades the scheme
to https:// even if APP_URL is http://:
use url;
// In env: APP_URL=http://app.example.com
secure;
// "https://app.example.com/login"
In production you typically set APP_URL to your HTTPS host once and
never call secure directly - the upgrade is for environments where
local development runs over HTTP but a specific link must be HTTPS
(e.g. a callback URL embedded in a payment session).
Reading the current URL
Inside a handler, the request itself is the source of truth:
use url;
async
| Helper | Returns | Source |
|---|---|---|
url::current(&req) |
path + query of this request | The current Request |
url::full(&req) |
absolute URL of this request | APP_URL + current(&req) |
url::previous(fallback) |
previous URL recorded by the session middleware | _previous.url in the session, or fallback |
previous is what backs Redirect::back - the session middleware
records the URL of every successful HTML GET so a form POST can bounce
back to the page that submitted it. Inertia partials, JSON-API requests
(Accept: application/json without text/html), and non-2xx/3xx
responses are skipped so you never bounce back to an intermediate
endpoint the user never saw.
Signed URLs
Signed URLs let you mint a URL that proves it came from your server,
without storing the URL anywhere. The signature is HMAC-SHA256 over the
canonical form of the URL using your APP_KEY; the server recomputes
the HMAC on the inbound request and accepts only matching signatures.
Reach for signed URLs when:
- Email-delivered links - password reset, email verification, invite-by-email, magic-link login. The URL has to survive a round trip through an inbox without being storable as opaque state.
- Ephemeral downloads - "your CSV export is ready" links that expire in 24 hours, signed S3 alternatives where you want the URL to remain on your domain.
- Webhooks pointing back at you - third-party callbacks that should refuse forged calls without requiring a database lookup per request.
use url;
use Utc;
// Permanent signed URL - never expires.
let link = signed_route?;
// "/password/reset/42/xyz?signature=ab12cd34..."
// Temporary signed URL - expires one hour from now.
let expires_at = now.timestamp + 3600;
let link = temporary_signed_route?;
// "/verify/email/42?expires=1748803600&signature=def012..."
Note that expires_at_epoch_seconds is an absolute UNIX timestamp,
not a duration. Compute it at the call site:
let one_hour_from_now = now.timestamp + 3600;
let one_day_from_now = now.timestamp + 86_400;
That keeps the helper signature small and lets you reuse the same function for both relative-from-now and explicit-absolute deadlines.
Verifying
On the inbound side, you verify the signature against the live request:
use ;
pub async
async
has_valid_signature returns true only when the HMAC matches AND the
URL is not expired. For the three-way distinction between invalid,
expired, and valid, use signature_verdict:
use ;
use SignatureVerdict;
pub async
async
signature_has_not_expired(&req) is deprecated and now answers exactly
what has_valid_signature answers. Reach for signature_verdict above
instead; a URL with no expires query parameter is "never expired" by
definition, in Suprnova as in Laravel.
Why Suprnova diverges
Laravel's URL::signatureHasNotExpired($request) is literally
"not expired", so a forged signature comes back true - it never had
an expiry to miss. Suprnova's used to match that. It doesn't any more: the
helper requires a valid signature first.
The reason is that expires is attacker-supplied until the HMAC says
otherwise, so no answer derived from it means anything before the signature
checks out - and a function whose name reads like a guard was letting every
forged URL through anything that called it alone.
Requiring validity collapses it into has_valid_signature, which is why it
carries a deprecation rather than a behaviour flag. That collapse is not a
loss: under a three-state verdict there is no "not expired" a single bool
can report honestly except Valid. If you want to tell expired from
invalid - to say "request a fresh link" instead of "forbidden" - that is
what signature_verdict is for, and it says it in the type.
Signing arbitrary URLs
If the URL you want to sign doesn't come from a registered named route -
a callback URL handed to you by a third party, a path constructed
dynamically at runtime - use signed_url directly:
use url;
let callback = signed_url?;
Pass None for the expiration to mint a permanent signature. The verify
side is the same - has_valid_signature(&req) doesn't care whether the
URL was minted from a named route or from a raw path.
Wire format
Two URLs that differ only by query-parameter order produce identical signatures because the canonical form sorts query pairs lexicographically before hashing. That matters because clients sometimes reorder query parameters in transit (proxies, link previewers, mobile email apps), and a signed URL that breaks under reordering would be unusable.
| Component | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Key | Active APP_KEY raw bytes |
| Payload | path?<sorted-query> (omit ? when no params) |
| Sort order | (key, value) - every pair, repeats included |
| Encoding | Hex-encoded 64-character digest |
| Comparison | Constant-time via subtle::ConstantTimeEq |
| Reserved keys | signature, expires |
Repeated keys are signed, not collapsed. ?tag=a&tag=b carries both
values into the payload, so neither can be added, removed, or substituted
without breaking the signature. Sorting on (key, value) rather than the
key alone is what keeps that order total, so the reordering guarantee
above still holds when a key appears more than once.
This is worth stating because the alternative bites hard. An earlier
version canonicalised into a map, which kept only the last value for a
repeated key. Request::query_param returned the first. So a
legitimately signed ?user=victim could be replayed as
?user=attacker&user=victim with the original signature: verification
saw victim and passed, and the handler acted on attacker. Signed and
executed were different URLs. All three query accessors - query_param,
query_params, and Context::query_param - now resolve a repeated key
to its last value, and the canonical form loses nothing.
A repeated signature or expires is refused outright. Those are
control parameters; two of either leaves no non-arbitrary answer to
"which one governs?", and the verifier should not be the component
guessing.
The HMAC payload excludes any pre-existing signature query parameter
(so signing-over-signing is a no-op) and re-emits a fresh expires value
from the call arguments. A client that strips or rewrites the expires
breaks the signature; a client that strips the signature fails as
Invalid. Both fail closed.
The fragment (#section) is stripped from the canonical form because
browsers never transmit fragments back to the server. Signing over a
fragment would invalidate every link the moment a client appended an
anchor - ?signature=...#docs would not verify on the server side.
Reserved query parameters
signature and expires are reserved query-parameter names. A route
that legitimately expects a query parameter called signature or
expires would collide with the signed-URL machinery, and the verifier
would mis-attribute the value. Either rename the parameter or wrap the
route's incoming parameters under a different namespace.
// Bad - `signature` collides with the reserved name.
get! // takes ?signature=hash
// Good - namespace it.
get! // takes ?body_signature=hash
The constants are exposed for symmetry with the Laravel wire format:
use ;
// SIGNATURE_KEY == "signature"
// EXPIRES_KEY == "expires"
Key rotation
Signed URLs use the same APP_KEY that powers Crypt::encrypt and
session-cookie integrity. Rotating APP_KEY invalidates every
previously-minted signature in flight - an in-flight password-reset
email becomes a 403 the next time the user clicks it.
For most applications that is the correct behaviour. If you need
graceful rotation with overlap (so old links keep working through a
deployment window), use APP_KEY_PREVIOUS to carry the prior key
forward; the keyring tries every installed key on verification. See the
Hashing chapter for the full keyring story.
Errors and edge cases
A handful of failure modes are worth knowing about:
route(name, ...)returnsNonewhen the name is not registered. This is the lenient surface - silent failure is intentional so calling code can fall back to a default. Usetry_routefor a loud failure.try_routereturnsErr(NameNotFound)for an unknown name andErr(MissingParams { name, missing })when a required{placeholder}has no matching value.url::signed_routeand friends returnFrameworkErrorwhen the encryption key isn't installed (e.g. you forgotAPP_KEYin.env). This fails at boot in production becauseCrypt::initruns duringServer::from_config; the error path here exists to surface misconfiguration loudly instead of producing unverifiable links.has_valid_signaturereturnsOk(false), notErr, for an invalid or expired signature. TheFrameworkErrorvariant is reserved for "the server can't even check" failures (missing key).- A signed URL with a tampered
expiresverifies asInvalid, notExpired. The HMAC payload includes theexpiresvalue, so changing it breaks the signature first.
use ;
// All of these are Invalid, not Expired:
signature_verdict?; // signature query param missing
signature_verdict?; // signature is non-hex junk
signature_verdict?; // path was tampered (/orders/1 → /orders/2)
signature_verdict?; // any query param value was tampered
signature_verdict?; // expires value was tampered
// This is Expired:
signature_verdict?; // valid HMAC, but now > expires
Why Suprnova diverges
Laravel's URL facade carries asset(), secureAsset(), assetFrom(),
and action(). Suprnova ships none of them - for deliberate reasons.
Assets. Suprnova's frontend story is Vite plus the filesystem disks
(Filesystem), not a stand-alone asset helper. Vite's
@vite('resources/app.ts') directive (or the Inertia adapter's equivalent)
emits the correct hashed URLs in production and the dev-server URL in
development. Building a parallel URL::asset() channel would split the
asset story across two systems that have to agree about hashing,
versioning, and which manifest is authoritative. The Vite side already
won that responsibility.
Action routing. Laravel's action('UserController@show', ['id' => 1])
relies on PHP class-string routing - controllers are classes with
methods, and the framework can reverse-look-up an action string. Rust
handlers are free functions. The closest analogue is named routes, and
route("users.show", &[("id", "1")]) is already the right interface.
Re-introducing action-string routing on top of Rust handler types would
add nothing real over named routes.
URL::forceScheme() / URL::forceRootUrl(). Laravel exposes these
for tests and for sites behind reverse proxies that don't pass
X-Forwarded-Proto. Suprnova handles both cases by configuration:
APP_URL carries the canonical host and scheme; for proxy environments,
the trusted-proxy middleware (Middleware) reads
X-Forwarded-* headers and updates the request URL before it reaches
your handler. There's nothing for forceScheme to override - APP_URL
already says what the scheme is.
What does land here is the user-facing shape consumers reach for, with the same Laravel-shaped names where they translate cleanly. The trim is intentional, not an oversight.
Next
- Routing - declaring routes, naming them, route groups, resource routing, and the full per-method matching surface
- Responses -
Redirect::route,Redirect::signed_route,Redirect::back, and the rest of the redirect helper family that consumes URL generation - Hashing -
APP_KEYlifecycle, key rotation, and the shared keyring that backs URL signing alongside encryption - Auth flows - the production users of signed URLs: password reset, email verification, and remember-me cookies
- Requests -
Request::path,Request::query,Request::route_is, and the reverse side of every helper in this chapter
