A password-reset email needs a link that works without a login but rejects an altered account ID. A signed link authenticates its path and query using a secret key. An expiry limits how long the link is accepted; a cache can record single use, and a server-side binding can invalidate links when account state changes.
A valid signature proves that the URL matches what a key holder signed. It does not identify the person opening it or independently authorize access to the named resource. Decide what possession of the link permits, authorize issuance, and recheck application constraints at the action boundary. Anyone who obtains the URL can present it: keep signed queries out of logs, analytics, and referrers, and deliver links over HTTPS.
Create Signers
Provision a separate random 32-byte shared key using
application key tooling. For example, generate the
application file link-key.key with arkoren key generate shared --out link-key.key; keep it outside source control and restrict access. Import
arkoren.dev/foundation/key and arkoren.dev/core/http/link, then load the key
at startup:
secret, err := key.LoadShared("link-key.key")
Handle loading failure before constructing the signer. For email links, give it your configured public origin rather than deriving that origin from an untrusted request host:
signer, err := link.New(secret, func(links *link.Builder) {
links.Origin("https://app.example.com")
})
Handle construction errors before issuing links or installing middleware. A zero
key returns ErrInvalidSecret. Origins must be HTTP(S) scheme/host origins,
without credentials, query, fragment, or an application path; invalid values
return ErrInvalidOrigin. Use HTTPS in production.
Without Origin, a signer accepts root-relative paths and returns relative URLs.
With an origin, it completes root-relative targets and accepts absolute URLs only
for that origin. Fragments, credentials, opaque URLs, and unsuitable targets
return ErrInvalidURL. A configured origin scopes link reconstruction; it is
not a substitute for your server’s host-routing policy.
Construct once and share the signer. It detaches owned key material and query-name containers, so retained drafts cannot reconfigure it. Cache/event services remain shared and application-owned; the signer does not start or stop them. Request-binding functions and captures must be safe for concurrent use.
Issue Links
After the application authorizes a reset request, build the target from a typed
account reference. With net/url imported, avoid concatenating raw query input:
type ResetTarget struct {
UserID string
}
target := ResetTarget{UserID: "user-42"}
query := url.Values{}
query.Set("user", target.UserID)
path := "/reset?" + query.Encode()
The target identifies the account selected by your application, not arbitrary
request fields that the signing service trusts automatically. Use Temporary
with time imported to issue a short-lived link:
signed, err := signer.Temporary(path, 15*time.Minute)
The result is an absolute URL under https://app.example.com with expires and
signature query parameters. The signature covers the path, ordinary query
values including user, and expiry; changing protected values invalidates it.
Signing replaces pre-existing reserved parameters. It does not encrypt query
values, so never put passwords or other secrets in them.
Temporary lifetimes must be positive; zero/negative returns ErrInvalidExpiry,
not a default. Expiry uses Unix-second precision and the verifier’s local clock;
equality counts as expired. Synchronize verifier clocks rather than treating the
TTL as a cross-server timing guarantee. Sign creates permanent links, which
cannot be consumed and have no automatic expiry.
If an email system appends tracking parameters, an alternative signer can exclude specific names:
signer, err := link.New(secret, func(links *link.Builder) {
links.Origin("https://app.example.com")
links.IgnoreQuery("utm_source", "utm_medium")
})
Anyone can add, change, or remove ignored values without invalidating the link.
Never derive account selection, redirect destinations, authorization, or bindings
from them. Empty names and reserved signature/expires names return
ErrInvalidIgnoreQuery. Signing an arbitrary redirect destination does not make
that destination safe; validate allowed destinations before issuance and use.
Protect Routes
Use link.Middleware on a display-only GET. With an existing router routes
and your application-owned showResetForm handler:
routes.With(link.Middleware(signer)).Get("/reset", showResetForm)
The handler runs only after signature and expiry checks. Middleware returns
403 Invalid Link for invalid authentication and 403 Link Expired for
authentic expired links. With a configured cache, it also returns
403 Link Already Used for a consumed temporary link. Cache-read failures pass
to application error handling; they are not proof that the link was forged.
The signature covers the URL, not the HTTP method. Register explicit methods,
not Any. If GET and POST intentionally share a path, GET must only display the
form; POST must independently verify and consume before changing state. This
also keeps email scanners and link previews from spending a reset link.
Verification uses the path as the router sees it; stripping a prefix can break the signed path. Request verification reconstructs absolute links using the configured origin, not forwarded host headers. Preserve the signed path and query through proxy routing and form submission.
Outside middleware, signer.Verify(signed) checks authenticity and expiry only.
Use errors.Is to classify ErrInvalidURL, ErrInvalidSignature, or
ErrExpired. Expiry is reported only after authentication. Verify neither
checks consumption nor reserves a use, even when a cache is configured.
Consume Links
Single use requires a cache shared by every server accepting the same links.
Choose cache storage that retains markers for their entire
lifetime; early eviction, data loss, or an in-memory restart lets spent links
work again. Construct this alternative signer with the existing *cache.Cache
named store:
signer, err := link.New(secret, func(links *link.Builder) {
links.Origin("https://app.example.com")
links.Cache(store)
})
Middleware only observes consumption. Spend the link in the POST handler after decoding and validating the proposed password and applying your application’s reset policy, but before committing the password change. Register that application-owned handler for POST explicitly:
routes.Post("/reset", resetPassword)
Inside resetPassword, with incoming request r, keep all consumption failures
off the action path:
err := signer.ConsumeRequest(r)
if errors.Is(err, link.ErrConsumed) {
return link.ErrLinkUsed
}
if err != nil {
return err
}
Import errors for classification. Only after this succeeds may your
application-owned password update run. The form must post to the URL carrying
the original signed query; ConsumeRequest reconstructs it without manual URL
assembly. Use Consume(ctx, signed) outside HTTP, or ConsumeURL with a parsed
URL.
Consumption verifies the signature, binding, and expiry, then atomically adds a
cache marker. Confirmed concurrent calls sharing that backend have one winner;
the others return ErrConsumed. A marker lives until signed expiry plus one
minute for clock differences. That margin retains markers longer; it does not
extend the link’s validity or guarantee arbitrary clock-skew tolerance.
A cache error can occur after a marker was committed but its confirmation was lost. Do not perform the action after any consumption error, and do not assume a retry can succeed. Confirm application state through your recovery flow or issue a new link.
Consumption is not a transaction with the password update. If the update fails after successful consumption, the link stays spent. If you need an atomic consume-and-update guarantee, enforce that boundary in application-owned durable storage; the cache marker does not provide it.
Only temporary links can be spent; authenticated permanent links return
ErrNotTemporary. Missing cache configuration returns ErrNoCache before
verification of a parsed target. Consume first parses its raw URL, so an
unparsable value returns ErrInvalidURL even without a cache; ConsumeURL and
ConsumeRequest check cache configuration before validating their target.
Bind State
A still-unexpired link might need to stop working after a password change.
Binding adds current server-side bytes to the authenticated input without
putting them in the URL. Load the account identified by target.UserID as user;
its PasswordHash is a persisted []byte. Issue a bound variant:
signed, err := signer.Temporary(path, 15*time.Minute, link.Bind(user.PasswordHash))
Use the same binding policy for every verification, consumption, and inspection. Read the current persisted bytes each time, not a stale issuance snapshot:
err := signer.Verify(signed, link.Bind(user.PasswordHash))
This succeeds while the stored bytes match; after they change, it returns
ErrInvalidSignature. A wrong, missing, or unexpected binding has the same
error. An empty binding is distinct from no binding. Bind copies its input;
only a digest participates in the signature.
Choose stable bytes: a stored hash or a dedicated reset-version value, not a freshly salted recomputation or an inconsistently formatted timestamp. Every verifier must read the current state from an appropriate consistency boundary. Replica lag or cached old values can delay rejection; reverting to old bytes can make old links verify again. Binding is not an atomic lock against a state change that races with the subsequent action.
For bound GET verification, configure a lookup. Here users.Find(ctx, id) is
your application-owned loader returning that account and an error:
binding := func(policy *link.MiddlewareBuilder) {
policy.BindRequest(func(r *http.Request) ([]byte, error) {
user, err := users.Find(r.Context(), r.URL.Query().Get("user"))
if err != nil {
return nil, err
}
return user.PasswordHash, nil
})
}
Import net/http for the callback. It runs before authentication: use the
query only for a bounded lookup, never as proof of permission, and perform no
state change there. Lookup errors reject with 403 Invalid Link. The returned
problem retains the cause for application error handling; response detail and
rejection events do not expose it. Redact causes before diagnostic logging.
Install the bound display route instead of the earlier unbound one:
routes.With(link.Middleware(signer, binding)).Get("/reset", showResetForm)
This does not configure POST automatically. In the POST handler, load the current account from the signed query, fail closed on lookup errors, and replace the earlier unbound consumption call with:
err := signer.ConsumeRequest(r, link.Bind(user.PasswordHash))
Keep the same explicit error checks before the password update. Enforce any required state-version condition in the application’s update as well; neither binding nor single-use markers authorize a different resource or transaction.
Inspect Links
For a recovery page, inspect an authentic link without spending it. With a
context ctx and current bound account:
details, err := signer.Inspect(ctx, signed, link.Bind(user.PasswordHash))
On success, details.Expired can be true: expiry is descriptive, not an
inspection error. Previous identifies a previous signing key; Consumed
reports a known cache marker. False does not guarantee usability: it is always
false without a cache or for permanent links, and another request can consume
immediately after inspection. All inspection errors return zero details,
including authentication failures and cache observation errors.
details.URL removes signature and expires but retains application paths,
query values, and ignored tracking values. It is not safe diagnostic text. If
your recovery policy permits reissuance, pass it to Temporary with the current
binding; inspection alone is not permission to extend a reset indefinitely.
Rotate Keys
To accept existing links during rotation, load current and previous as
separate key.Shared values, then use an alternative construction:
signer, err := link.New(current, func(links *link.Builder) {
links.Origin("https://app.example.com")
links.Previous(previous)
})
Retain any required cache, binding, or ignored-query policy in the replacement configuration. New links use the current key; old keys verify only. Coordinate all issuers/verifiers before removing a previous key. Removing it invalidates its remaining links, including permanent links, which never expire naturally. If a key leaks, continuing to accept it also accepts forged links made with it; choose invalidation over a graceful overlap when that risk requires it.
Observe Outcomes
Configure Events(events) on the signer with your application’s event bus if
you need outcome counts. Confirmed consumption publishes link.consumed;
failures, including uncertain writes and repeat use, publish nothing. Middleware
publishes link.rejected for invalid authentication, not authentic expiry or
already-used outcomes. Verify and Inspect publish neither event.
Payloads omit paths, queries, bindings, and diagnostic errors. They carry
Method, which is empty for non-request consumption. Do not replace removed
Path/Err field reads with full request URLs or causes. Context values remain
available and custom HTTP methods are client-controlled, so select safe
attributes explicitly. HTTP problem instance can still contain a request
path; event privacy does not scrub it. See
signed-link events.
link.Config is a printable diagnostic record, not an input to New. Its
formatting redacts previous keys and omits service pointers, but displays origin
and ignored query names. Keep credentials out of those settings; this does not
sanitize arbitrary signers, URLs, returned errors, or inspection results.
For URL variants, inspection fields, and error contracts, see the
link API reference.