A signed URL is a normal link with two extra query parameters: expires (a Unix time) and signature (an HMAC-SHA256 of the path and query, keyed from APP_KEY). The server can then trust the link's contents without a database lookup: if someone changes /unsubscribe/42 to /unsubscribe/43, the signature no longer matches and the request gets 403.
Use them for links you send by email or hand to another system: unsubscribe, email verification, "download your export", a private file for the next 10 minutes.
Step by step#
1. Derive a key and create a signer at boot
func NewSigner(app *vukem.App) (*signed.Signer, error) {
key, err := app.DeriveKey("signed.urls", 32) // config.ErrKeyMissing without APP_KEY
if err != nil {
return nil, err
}
// Links signed before an APP_KEY rotation verify until the old key
// leaves APP_PREVIOUS_KEYS; new links always use APP_KEY.
previous := app.DerivePreviousKeys("signed.urls", 32)
return signed.New(key).WithPreviousKeys(previous...), nil // panics on a key shorter than 32 bytes
}DeriveKey turns APP_KEY into a separate key per purpose string, so a signature made for links can never be reused as, say, an encryption key.
2. Protect the route with the signer's middleware
r.Get("/unsubscribe/{id}", newsletter.Unsubscribe).
Name("newsletter.unsubscribe").
Use(signer.Middleware()) // 403 when the signature is missing, wrong or expired3. Sign the link when you send it
link, err := signer.Sign(
fmt.Sprintf("%s/unsubscribe/%d", appURL, user.ID),
7*24*time.Hour, // valid for one week
)
if err != nil {
return err
}
// https://example.com/unsubscribe/42?expires=1767225600&signature=9vT0...appURL is app.Config.URL (APP_URL). Only the path and query are signed, so the same link works behind a proxy or on another host name of the same app.
4. Handle the request as usual
func (h *Newsletter) Unsubscribe(c *web.Ctx) error {
id, err := c.ParamInt("id") // safe to trust: the signature covered it
if err != nil {
return err
}
if err := h.subs.Unsubscribe(c.Context(), id); err != nil {
return err
}
return c.Render("newsletter/unsubscribed", nil)
}Checking by hand#
signer.Verify(r) returns nil, signed.ErrInvalid or signed.ErrExpired (both 403 errs.HTTPError values), for when you want a friendly page instead of a bare 403:
if err := signer.Verify(c.Request()); errors.Is(err, signed.ErrExpired) {
return c.Render("links/expired", nil) // offer to send a new link
} else if err != nil {
return err
}The middleware logs an url.signature_invalid security event for tampered links (not for expired ones).
Temporary file downloads#
storage.TemporaryURL builds on the same signer to hand out private files for a limited time:
link, err := storage.TemporaryURL(signer, "/files", "invoices/2026-10.pdf", 10*time.Minute)See Storage for serving the disk with storage.Handler(disk, signer).
Testing#
link, err := signer.Sign("http://localhost/unsubscribe/42", time.Hour)
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(link)
app.Client(t).Get(u.RequestURI()).AssertOK()
app.Client(t).Get("/unsubscribe/42").AssertStatus(http.StatusForbidden) // unsignedPitfalls#
ttl0 means the link never expires. Always pass a lifetime unless a permanent link is the point (and even then, prefer a long one).- Any change to the query breaks the link. Tracking parameters added by an email service (
?utm_source=…) invalidate the signature. Disable link rewriting for these emails, or put the tracking somewhere else. - Rotating
APP_KEYinvalidates every link already sent. Plan rotations, or keep links short-lived. - The host is not signed. Two apps that share an
APP_KEYaccept each other's links for the same path. Give each app its own key. - Do not build the signer per request. Create it once at boot and pass it to the handlers that need it.
Security notes#
- A signed URL is a bearer credential: whoever has the link can use it until it expires. It proves the link came from you, not who is clicking it. For actions on an account (email verification in the
install:authscaffold), also require the logged-in user, as the scaffold does withauth.Required. - Make the action safe to repeat: links get clicked twice, and email scanners open links before the user does. For destructive actions, show a confirmation page with a
POSTform instead of acting on theGET. - The signature is checked in constant time, and
signed.Newrefuses keys shorter than 32 bytes.