Help us shape the future of Go! Take the 2026 Go Developer Survey

link

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 19 Imported by: 0

README

Accept signed actions without leaking their credentials

Use link when possession of a server-issued URL authorizes an application action. Load an independent key.Shared through the key package before constructing the signer; do not generate a replacement key to repair missing startup configuration.

For an existing validated secret and router routes:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Origin("https://app.example.com")
})
if err != nil {
	return err
}
routes.With(link.Middleware(signer)).Get("/download", download)

The configured origin, not the request Host header, determines absolute-link verification. Without an origin, the signer accepts root-relative paths only. The signature does not cover the HTTP method: register only the methods your action permits. Signing and verification must see the same path; stripping a prefix changes that path. Ignored query parameters are not authorization input.

Configure a shared cache with Builder.Cache, create a positive-lifetime temporary link, and call ConsumeRequest in the action handler before committing the change. Middleware only checks validity and known consumption; it does not spend links. A successful consume followed by a failed action leaves the link spent. Issue a new link through your application's recovery flow.

A cache error does not prove that consumption failed: the marker may have been committed before confirmation was lost. Stop the action after any error. A cache that loses markers before their TTL permits replay, so choose retention and availability appropriate to your action. Cache key identities and encoded URLs are unchanged by event privacy protections.

Bind can revoke links when server-side state changes. A BindRequest lookup runs before signature verification, so its URL inputs are untrusted. Validate inputs and redact callback error causes before application-controlled logging.

Observe outcomes without publishing URLs or causes

Builder.Events enables Rejected and Consumed. Their payloads contain only the HTTP Method, empty for direct Consume/ConsumeURL calls. Path and the rejection's raw Err were removed because paths and error messages can contain credentials. The returned rejection problem still preserves its original cause for errors.Is/errors.As; event listeners do not receive it as payload.

Event contexts retain caller values, and custom HTTP methods are client-controlled. HTTP problem instance rendering is unchanged and can contain the request path. Inspection.URL still contains application paths and query values even after the signature and expiry are removed. Config formatting redacts rotation keys and omits service pointers, but leaves Origin and ignored parameter names visible. These interfaces are not general-purpose sanitizers: select safe data explicitly.

Documentation

Overview

Package link creates and verifies signed application links that can expire, be used once, or be revoked when state changes.

A signed link carries a "signature" query parameter that proves the server issued it and nobody altered it. Use signed links for email verification, unsubscribe, file downloads, and password reset, where the recipient holds the only proof of authorization. Build one Signer with New at startup and share it: it is immutable and safe for concurrent use. Load a key.Shared with key.LoadShared or key.ParseShared before construction.

signer, err := link.New(
	secret,
	func(signer *link.Builder) {
		signer.Origin("https://app.example.com")
	},
)
if err != nil {
	return err
}
signed, err := signer.Sign("/unsubscribe?user=42")
if err != nil {
	return err
}
if err := signer.Verify(signed); err != nil {
	return err
}

Absolute and relative signers

A signer with Builder.Origin set is absolute. It signs the scheme and host as well as the path, so a link for one origin never verifies for another. It accepts a path such as "/files/a.pdf", which it completes with the configured origin, or a full URL whose scheme and host match the origin (default ports are ignored). A different origin returns ErrInvalidURL. The origin must be an HTTP or HTTPS origin without a path, query, fragment, or user info, or New returns ErrInvalidOrigin.

A signer with an empty origin is relative. It signs the path and query only and accepts only values that start with "/". A URL with a scheme or host returns ErrInvalidURL. Use it when the same application is reachable under several hostnames.

Signer.Sign creates a permanent link, which stays valid until the secret is retired. Signer.Temporary creates a link that expires after a ttl:

signed, err := signer.Temporary("/reset?user=42", 15*time.Minute)

The expiry is stored as a Unix timestamp in the "expires" query parameter and is covered by the signature. A ttl that is not positive returns ErrInvalidExpiry. Signer.SignURL and Signer.TemporaryURL do the same for a parsed url.URL without mutating it. A "signature" or "expires" parameter already present in the input is replaced, never trusted.

What is signed

The signature covers the canonical form of the link: for absolute signers the lowercased scheme and host with default ports dropped, then the escaped path, then the query sorted by name, including "expires". It excludes the "signature" parameter and any Builder.IgnoreQuery names.

The signature does not cover the HTTP method, and the URL must not contain a fragment: a link with a fragment returns ErrInvalidURL, because browsers never send fragments to the server.

Protecting routes

Middleware returns middleware that verifies each request and calls the next handler only for valid links. Register a signed route for a single method so a link issued for GET cannot be replayed as POST; the router answers other methods with 405 before the middleware runs.

r := router.New()
r.With(link.Middleware(signer)).Get("/unsubscribe", unsubscribe)

An absolute signer verifies against its configured origin, not the request Host header, which clients control. Verification uses the request path as the router sees it, so wrapping a handler in http.StripPrefix breaks verification: sign and verify the same path.

A temporary link can be spent once. Give the signer a cache with Builder.Cache and call Signer.Consume in the handler that applies the change; Middleware only checks. A password reset shows its form on GET, which any valid link may do, and burns the link on POST:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Cache(cache.New(memorycache.New()))
})
if err != nil {
	return err
}

r.With(link.Middleware(signer)).Get("/reset", showResetForm)
r.Post("/reset", func(w http.ResponseWriter, r *http.Request) error {
	err := signer.ConsumeRequest(r)
	if errors.Is(err, link.ErrConsumed) {
		return link.ErrLinkUsed.Derive(func(details *problem.Builder) {
			details.Error(err)
		})
	}
	if err != nil {
		return link.ErrLinkRejected.Derive(func(details *problem.Builder) {
			details.Error(err)
		})
	}

	return resetPassword(r)
})

The POST request carries the signed URL in its query, so the handler needs no URL rebuilding. Signer.Consume and Signer.ConsumeURL take the link directly.

Consume verifies the link like Signer.Verify, then records the hash of its signature in the cache with an atomic add under the key prefix "link:v1:consumed:". Concurrent calls, including from several servers sharing a cache driver, have exactly one winner; the others get ErrConsumed. The record lives until the link expires plus one minute for clock skew between servers, after which expiry alone rejects the link. Only temporary links can be consumed: a permanent link returns ErrNotTemporary, since its record would have to last forever. A signer without a cache returns ErrNoCache.

Consume first, then commit the side effect. That gives at-most-once behavior: if the side effect fails after Consume succeeded, the link is spent and the user must request a new one. A cache that evicts entries early lets a spent link work again, so use a driver that keeps them for their TTL. A cache error is uncertain: its Add may have committed before confirmation was lost. Do not proceed with the action after any error or assume the link remains available. Confirm state or issue a new link through your recovery flow.

With a cache, Middleware also rejects temporary links that were already consumed with ErrLinkUsed ("Link Already Used", 403), so a form page does not render for a dead link. It only reads the cache and publishes no Rejected event: like an expired link, a used link is authentic.

Bind adds server-side state to the signature, which revokes links without a list of revoked links. Bind a reset link to the user's password hash and it stops working the moment the password changes:

signed, err := signer.Temporary("/reset?user=42", time.Hour, link.Bind(user.PasswordHash))
if err != nil {
	return err
}
if err := signer.Verify(signed, link.Bind(user.PasswordHash)); err != nil {
	return err
}
// After the password changes:
err = signer.Verify(signed, link.Bind(user.PasswordHash)) // ErrInvalidSignature

Pass the same Bind to every Sign, Temporary, Verify, Consume, and Inspect call for the link. For Middleware, pass MiddlewareBuilder.BindRequest to derive the binding from the request:

link.Middleware(signer, 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
	})
})

The function runs before the signature is checked, so treat everything it reads from the request as untrusted. An error from it rejects the link with ErrLinkRejected. The returned problem retains the cause; the Rejected payload and HTTP error detail do not contain it. Redact causes before logging them: application/provider errors can contain credentials or full URLs.

A binding enters the signed text only as a SHA-256 digest on its own final line, so the link discloses nothing about it, and links signed without Bind keep verifying as before. A wrong or missing binding, or a binding on a link signed without one, returns ErrInvalidSignature.

Signer.Inspect authenticates a link and describes it without acting on it. Unlike Verify, an expired link is not an error:

details, err := signer.Inspect(ctx, rawURL)
switch {
case err != nil:
	// Forged or malformed: no details are returned.
case details.Expired:
	// Offer a new link for details.URL.
case details.Previous:
	// Signed with a previous secret: re-issue it before rotation ends.
}

The Inspection carries the URL without its "signature" and "expires" parameters, the expiry, whether the link expired, whether a previous secret signed it, and, when the signer has a cache, whether it was consumed. Consumed is false without a cache and for permanent links, so false means "not known to be spent". Inspect never changes state.

Secrets and rotation

Every secret is a validated key.Shared of exactly MinSecretLength bytes. New rejects zero values with ErrInvalidSecret. The package derives its MAC key with HKDF-SHA256; still provision separate material for unrelated purposes.

To rotate, move the old secret to Builder.Previous and pass a new secret to New. New links use the new secret, and links signed with the old one still verify. Drop the old secret after the longest lifetime of the links it signed; for permanent links, dropping it invalidates them.

link.New(
	newSecret,
	func(signer *link.Builder) {
		signer.Previous(oldSecret)
	},
)

Ignored query parameters

Mail clients and analytics add parameters such as utm_source to links. List them in Builder.IgnoreQuery so they may be added, changed, or removed without invalidating the link:

link.New(secret, func(signer *link.Builder) {
	signer.IgnoreQuery("utm_source", "utm_medium")
})

The setting belongs to the signer, so signing and verifying always agree. Ignored parameters are not protected: never read authorization-relevant data from them. The names "signature" and "expires" are reserved, and an empty or reserved name returns ErrInvalidIgnoreQuery.

Errors

Signer.Verify returns ErrInvalidURL for parsing and signer-mode failures, and ErrInvalidSignature for malformed authentication data, unauthenticated, or tampered links. Authentication errors carry no further detail, so callers cannot learn which signature or binding component failed.

A link that authenticates but is past its expiry returns an *ExpiredError, which matches ErrExpired with errors.Is. Use errors.As to read ExpiredError.ExpiresAt:

var expired *link.ExpiredError
if errors.As(err, &expired) {
	// Offer to send a new link; expired.ExpiresAt says when it lapsed.
}

An expiry is reported only after the signature verifies, so an attacker cannot probe expiry times of forged links.

Middleware turns these into safe responses: ErrLinkExpired (403, "Link Expired") for expired links, ErrLinkUsed (403, "Link Already Used") for consumed links, and ErrLinkRejected (403, "Invalid Link") for everything else. The underlying error is attached for logging but is not sent to the client.

Signing and verifying functions return ErrInvalidURL for a URL the signer's mode does not accept. New returns ErrInvalidSecret, ErrInvalidOrigin, or ErrInvalidIgnoreQuery for invalid configuration.

Events

With Builder.Events, Middleware publishes Rejected ("link.rejected") with the method for every invalid link; expired and consumed links publish nothing. A Signer.Consume, Signer.ConsumeURL, or Signer.ConsumeRequest call that spends a one-time link publishes Consumed ("link.consumed") with the method when a request is known; direct URL calls leave it empty. Paths, queries, bindings, and raw causes are omitted because each can contain secrets. Failed and repeated consumption publish nothing. Calls to Signer.Verify and Signer.Inspect publish nothing. Event contexts retain caller/request context values; these payload limits do not sanitize context values or the HTTP problem's normal instance member.

Index

Examples

Constants

View Source
const (

	// MinSecretLength is the required byte length of every signing key. Nonzero
	// key.Shared values enforce it before signer construction; larger keys are
	// not accepted either. It is a size requirement, not a source of randomness.
	//
	// Example:
	//	logger.InfoContext(ctx, "link key requirements", "bytes", link.MinSecretLength)
	MinSecretLength = 32
)

Variables

View Source
var Consumed = event.NewTopic[ConsumedEvent]("link.consumed")

Consumed is published when Signer.Consume, Signer.ConsumeURL, or Signer.ConsumeRequest spends a one-time link, after the use is recorded. Calls that fail, including ErrConsumed for a link spent earlier, publish nothing, and so does Middleware, which never consumes a link.

Example:

unsubscribe, err := events.Listen(link.Consumed, onConsumed)
if err != nil {
	return err
}
defer unsubscribe()
View Source
var ErrConsumed = errors.New("link was already used")

ErrConsumed reports an authentic temporary link that was already consumed. Return ErrLinkUsed to the client. Expired or unauthenticated links fail before the consumed-marker lookup.

Example:

if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrConsumed) {
	return link.ErrLinkUsed
}
View Source
var ErrExpired = errors.New("link has expired")

ErrExpired reports an authenticated temporary link whose expiry has passed. Verification returns an *ExpiredError that matches it with errors.Is. An unauthenticated link never reveals whether its apparent expiry passed.

Example:

if err := signer.Verify(rawURL); errors.Is(err, link.ErrExpired) {
	return link.ErrLinkExpired
}
View Source
var ErrInvalidExpiry = errors.New("link expiry is invalid")

ErrInvalidExpiry reports a temporary link lifetime that is not positive. Temporary links require an explicit positive TTL; zero does not pick a default.

Example:

_, err := signer.Temporary("/reset", 0)
if errors.Is(err, link.ErrInvalidExpiry) {
	return err
}
View Source
var ErrInvalidIgnoreQuery = errors.New("link ignored query parameter is invalid")

ErrInvalidIgnoreQuery reports an empty or reserved ignored query parameter. The signature and expiry names cannot be removed from authentication policy.

Example:

_, err := link.New(secret, func(signer *link.Builder) {
	signer.IgnoreQuery("signature")
})
if errors.Is(err, link.ErrInvalidIgnoreQuery) {
	return err
}
View Source
var ErrInvalidOrigin = errors.New("link signing origin is invalid")

ErrInvalidOrigin reports an origin unsuitable for absolute link signing. Only HTTP(S) scheme/host origins without application paths are accepted.

Example:

_, err := link.New(secret, func(signer *link.Builder) {
	signer.Origin("https://app.example.com/account")
})
if errors.Is(err, link.ErrInvalidOrigin) {
	return err
}
View Source
var ErrInvalidSecret = errors.New("link signing secret is invalid")

ErrInvalidSecret reports an invalid zero current or previous key.Shared. The key package enforces MinSecretLength before publishing nonzero values.

Example:

_, err := link.New(key.Shared{})
if errors.Is(err, link.ErrInvalidSecret) {
	fmt.Println("configure a link key")
}
View Source
var ErrInvalidSignature = errors.New("link signature is invalid")

ErrInvalidSignature reports a malformed or unauthenticated signed link. It deliberately does not identify which signature or binding component failed.

Example:

if err := signer.Verify(rawURL); errors.Is(err, link.ErrInvalidSignature) {
	return link.ErrLinkRejected
}
View Source
var ErrInvalidURL = errors.New("link URL is invalid")

ErrInvalidURL reports a URL unsuitable for the signer's configured mode. It also classifies parse failures without attaching the potentially secret URL.

Example:

_, err := signer.Sign("mailto:person@example.com")
if errors.Is(err, link.ErrInvalidURL) {
	return err
}
View Source
var ErrLinkExpired = problem.New(func(details *problem.Builder) {
	details.Type("tag:arkoren.dev,2026-09:core:http/link:link-expired")
	details.Title("Link Expired")
	details.Detail("The link has expired. Request a new one.")
	details.Status(http.StatusForbidden)
})

ErrLinkExpired is the safe response returned for authentic but expired links.

Example:

failure := link.ErrLinkExpired.Derive(func(details *problem.Builder) {
	details.Detail("The request cannot be completed.")
})
_ = failure.Status()
View Source
var ErrLinkRejected = problem.New(func(details *problem.Builder) {
	details.Type("tag:arkoren.dev,2026-09:core:http/link:link-rejected")
	details.Title("Invalid Link")
	details.Detail("The link is invalid, has been altered, or has expired.")
	details.Status(http.StatusForbidden)
})

ErrLinkRejected is the safe response returned for rejected signed requests.

Example:

failure := link.ErrLinkRejected.Derive(func(details *problem.Builder) {
	details.Detail("The request cannot be completed.")
})
_ = failure.Status()
View Source
var ErrLinkUsed = problem.New(func(details *problem.Builder) {
	details.Type("tag:arkoren.dev,2026-09:core:http/link:link-used")
	details.Title("Link Already Used")
	details.Detail("The link has already been used. Request a new one.")
	details.Status(http.StatusForbidden)
})

ErrLinkUsed is the safe response returned for authentic links that were already consumed. Return it from a handler when Signer.Consume reports ErrConsumed; Middleware returns it for consumed links.

Example:

failure := link.ErrLinkUsed.Derive(func(details *problem.Builder) {
	details.Detail("The request cannot be completed.")
})
_ = failure.Status()
View Source
var ErrNoCache = errors.New("link signer has no cache")

ErrNoCache reports that Signer.Consume was called on a signer created without Builder.Cache. A malformed raw URL is rejected before this configuration check.

Example:

if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrNoCache) {
	return err // configure a shared cache before accepting one-time actions
}
View Source
var ErrNotTemporary = errors.New("link is not temporary")

ErrNotTemporary reports that Signer.Consume was called on a permanent link. A permanent link never expires, so its consumed record would have to be kept forever. Use Temporary rather than Sign when the action must have a single use.

Example:

if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrNotTemporary) {
	return err
}
View Source
var Rejected = event.NewTopic[RejectedEvent]("link.rejected")

Rejected is published when Middleware rejects a link whose signature or form is invalid. Expired links publish nothing: they are an ordinary outcome of a valid link. Event contexts retain request context values; listeners must select safe attributes rather than dumping the context or a returned error.

Example:

unsubscribe, err := events.Listen(link.Rejected, onRejected)
if err != nil {
	return err
}
defer unsubscribe()

Functions

func Middleware

func Middleware(signer *Signer, configure ...MiddlewareBuilderFunc) router.Middleware

Middleware returns middleware that permits only requests signed by signer.

The signature covers the URL, not the HTTP method, so a link is accepted for any method its route accepts. Register a signed route for a single method (for example with router.Router.Get), never with router.Router.Any or router.Router.Methods, so a link signed for GET cannot be replayed as POST: the router answers other methods with 405 before this middleware runs. Verification uses the request's URL path as the router sees it; behind http.StripPrefix the path no longer matches the signed URL.

Pass MiddlewareBuilder.BindRequest to verify links signed with Bind. When the signer has a cache (Builder.Cache), a temporary link that Signer.Consume already spent is answered with ErrLinkUsed, so a form page does not render for a dead link. The check only reads the cache; the middleware never consumes a link. A cache failure is returned as an ordinary error.

With Builder.Events on the signer, a rejected link that has not merely expired or been used publishes Rejected: expired and used links are authentic, so they are ordinary outcomes and publish nothing.

Example:

r.With(link.Middleware(signer)).Get("/reset", showResetForm)
Example

ExampleMiddleware shows applying signature verification before a router handler so a valid signed request can proceed.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"arkoren.dev/core/http/link"
	"arkoren.dev/core/http/router"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	routes := router.New()
	routes.With(link.Middleware(signer)).Get("/unsubscribe", func(writer http.ResponseWriter, _ *http.Request) error {
		_, err := fmt.Fprint(writer, "unsubscribed")

		return err
	})

	signed, err := signer.Sign("/unsubscribe?user=42")

	if err != nil {
		fmt.Println(err)

		return
	}

	for _, target := range []string{signed, "/unsubscribe?user=43"} {
		recorder := httptest.NewRecorder()

		routes.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
		fmt.Println(recorder.Code)
	}
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
200
403

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder configures New before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Origin("https://app.example.com")
})
if err != nil {
	return err
}
_ = signer

func (*Builder) Cache

func (signer *Builder) Cache(store *cache.Cache) *Builder

Cache records consumed single-use links and lets Middleware reject them. It must be shared by every server that verifies the same links. The default, nil, disables Signer.Consume, which then returns ErrNoCache.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Cache(cache.New(memorycache.New()))
})
if err != nil {
	return err
}
_ = signer

func (*Builder) Events

func (signer *Builder) Events(bus *event.Bus) *Builder

Events receives Rejected from Middleware and Consumed from the Consume methods. The default, nil, publishes nothing.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Events(event.New())
})
if err != nil {
	return err
}
_ = signer

func (*Builder) IgnoreQuery

func (signer *Builder) IgnoreQuery(names ...string) *Builder

IgnoreQuery names query parameters excluded from the signature. They may be added, changed, or removed without invalidating a link. The names "signature" and "expires" are reserved and cannot be listed. The default, none, signs every query parameter.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.IgnoreQuery("utm_source")
})
if err != nil {
	return err
}
_ = signer

func (*Builder) Origin

func (signer *Builder) Origin(origin string) *Builder

Origin is the public HTTP or HTTPS origin of absolute links. The default, empty, creates a relative signer that signs path-and-query values only.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Origin("https://app.example.com")
})
if err != nil {
	return err
}
_ = signer

func (*Builder) Previous

func (signer *Builder) Previous(secrets ...key.Shared) *Builder

Previous sets the shared keys that still verify links during rotation. Each must contain exactly MinSecretLength bytes. The default, none, accepts only links signed with the current secret.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

signer, err := link.New(secret, func(signer *link.Builder) {
	signer.Previous(previousSecret)
})
if err != nil {
	return err
}
_ = signer

type BuilderFunc

type BuilderFunc = func(*Builder)

BuilderFunc edits one signer draft synchronously. Callbacks run directly once in order, including invalid required keys; nil entries panic. Expanded nil or empty slices omit edits. The completed signer detaches owned settings.

Example:

var policy link.BuilderFunc = func(signer *link.Builder) {
	signer.Origin("https://app.example.com")
}
signer, err := link.New(secret, policy)
if err != nil {
	return err
}
_ = signer

type CallOption

type CallOption func(*callConfig)

CallOption changes one call to a Signer method, rather than the published signer. Options apply in order; nil entries panic. Use the same Bind policy when signing and verifying a bound link.

Example:

options := []link.CallOption{link.Bind(user.PasswordHash)}
if err := signer.Verify(rawURL, options...); err != nil {
	return err
}

func Bind

func Bind(value []byte) CallOption

Bind covers value with the signature, so the link verifies only when the verifier supplies the same value again. Use it for server-side state that should revoke links when it changes, such as a password hash or an updated-at timestamp: every link issued before the change stops verifying without any stored list of revoked links.

Pass the same Bind to the signing call and to every Signer.Verify, Signer.Consume, and Signer.Inspect call for the link. A wrong or missing binding, and a binding on a link signed without one, returns ErrInvalidSignature, which reveals nothing about which part failed. An empty value is a binding like any other, distinct from no binding. Links signed without Bind keep verifying without it.

The value is copied and only its SHA-256 digest enters the signed text, so the link never discloses it. Use MiddlewareBuilder.BindRequest with Middleware.

Example:

signed, err := signer.Temporary("/reset?user=42", time.Hour, link.Bind(user.PasswordHash))
if err != nil {
	return err
}
if err := signer.Verify(signed, link.Bind(user.PasswordHash)); err != nil {
	return err
}
Example

ExampleBind shows binding a link to application state so verification requires the same state value.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Sign("/reset?user=42", link.Bind([]byte("password-hash-1")))

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed, link.Bind([]byte("password-hash-1"))))

	// After the password changes, the old link stops verifying.
	fmt.Println(signer.Verify(signed, link.Bind([]byte("password-hash-2"))))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
link signature is invalid

type Config

type Config struct {
	// Origin is the public HTTP or HTTPS origin of absolute links. The
	// default, empty, creates a relative signer that signs path-and-query values only.
	//
	// Example:
	//	record := link.Config{Origin: "https://app.example.com"}
	//	logger.InfoContext(ctx, "link settings", "settings", record)
	Origin string

	// PreviousSecrets still verify links during secret rotation. Each must be
	// exactly [MinSecretLength] bytes. The default, none, accepts only
	// links signed with the current secret.
	//
	// Example:
	//	record := link.Config{PreviousSecrets: []key.Shared{previous}}
	//	slog.Info("link settings", "settings", record)
	PreviousSecrets []key.Shared

	// IgnoreQuery names query parameters excluded from the signature. They may
	// be added, changed, or removed without invalidating a link. The names
	// "signature" and "expires" are reserved and cannot be listed. The default,
	// none, signs every query parameter.
	// This reporting record does not copy caller-owned slices or validate them;
	// use Builder.IgnoreQuery for detached construction policy.
	//
	// Example:
	//	record := link.Config{IgnoreQuery: []string{"utm_source"}}
	//	logger.InfoContext(ctx, "link settings", "settings", record)
	IgnoreQuery []string

	// Events receives [Rejected] from [Middleware] and [Consumed] from the
	// Consume methods. The default, nil, publishes nothing.
	//
	// Example:
	//	record := link.Config{Events: events}
	//	_ = record.Events // the live bus is not printed by Config formatting
	Events *event.Bus

	// Cache records consumed single-use links and lets [Middleware] reject
	// them. It must be shared by every server that verifies the same links.
	// The default, nil, disables [Signer.Consume], which then returns
	// [ErrNoCache].
	//
	// Example:
	//	record := link.Config{Cache: store}
	//	_ = record.Cache // the live service is omitted from formatted diagnostics
	Cache *cache.Cache
}

Config is a printable signer-settings record retained for redacted logging. It is not a constructor input; configure New with Builder callbacks. Formatting omits service pointers and replaces previous secrets with a marker.

Example:

record := link.Config{Origin: "https://app.example.com"}
slog.Info("link settings", "settings", record)

func (Config) GoString

func (config Config) GoString() string

GoString formats config like Config.String for %#v. Origin and query names remain diagnostic configuration, not secret values.

Example:

text := fmt.Sprintf("%#v", link.Config{PreviousSecrets: []key.Shared{previous}})
_ = text // previous keys are represented by a redaction marker

func (Config) LogValue

func (config Config) LogValue() slog.Value

LogValue implements slog.LogValuer with the redacted form of config. Only rotation keys and services are omitted; supply a non-secret Origin.

Example:

logger.InfoContext(ctx, "link settings", "settings", link.Config{Origin: "https://app.example.com"})

func (Config) String

func (config Config) String() string

String formats config with PreviousSecrets replaced by "[REDACTED]" when set, so %v and %+v never print them. It omits service pointers, but does not sanitize Origin or query-name strings.

Example:

record := link.Config{PreviousSecrets: []key.Shared{previous}}
text := record.String()
_ = text // includes PreviousSecrets:[REDACTED], never the key bytes

type ConsumedEvent

type ConsumedEvent struct {
	// Method is the request method. It is empty when the link was consumed
	// with [Signer.Consume] or [Signer.ConsumeURL], which have no request.
	// Custom methods are client-controlled and must not contain secrets.
	//
	// Example:
	//	logger.InfoContext(ctx, "link consumed", "method", consumed.Method)
	Method string
}

ConsumedEvent reports a recorded single use without a URL, resource path, binding, cache key, or digest. Direct URL calls have no HTTP method to report. The event context retains the caller's context values, which are not redacted.

Example:

unsubscribe, err := events.Listen(link.Consumed, func(ctx context.Context, consumed link.ConsumedEvent) error {
	logger.InfoContext(ctx, "link consumed", "method", consumed.Method)
	return nil
})
if err != nil {
	return err
}
defer unsubscribe()

type ExpiredError

type ExpiredError struct {
	// ExpiresAt is the time the link stopped being valid, with second precision.
	//
	// Example:
	//	var expired *link.ExpiredError
	//	if errors.As(err, &expired) {
	//		logger.InfoContext(ctx, "link expired", "expires_at", expired.ExpiresAt)
	//	}
	ExpiresAt time.Time
}

ExpiredError reports an authenticated temporary link whose expiry has passed and carries the moment it expired. Verification produces this timestamp only after successful authentication. Use errors.As to inspect it, or errors.Is with ErrExpired when it is unnecessary.

Example:

var expired *link.ExpiredError
if err := signer.Verify(rawURL); errors.As(err, &expired) {
	logger.InfoContext(ctx, "link expired", "expires_at", expired.ExpiresAt)
}
Example

ExampleExpiredError shows how to distinguish an expired link from other verification failures and extract the expiration timestamp when present.

package main

import (
	"errors"
	"fmt"
	"time"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Temporary("/reset?user=42", time.Hour)

	if err != nil {
		fmt.Println(err)

		return
	}

	var expired *link.ExpiredError

	switch err := signer.Verify(signed); {
	case errors.As(err, &expired):
		fmt.Printf("link expired at %s, request a new one\n", expired.ExpiresAt.Format(time.RFC3339))
	case err != nil:
		fmt.Println("invalid link")
	default:
		fmt.Println("valid link")
	}
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}

func (*ExpiredError) Error

func (expiredError *ExpiredError) Error() string

Error describes authenticated expiry in UTC RFC3339 without a URL, signature, or binding. The timestamp reflects second precision from the signed URL.

Example:

expired := &link.ExpiredError{
	ExpiresAt: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC),
}
text := expired.Error() // "link expired at 2026-10-30T12:00:00Z"
_ = text

func (*ExpiredError) Is

func (expiredError *ExpiredError) Is(target error) bool

Is reports whether target is ErrExpired. Use errors.Is so wrapping added by an application does not hide this identity.

Example:

expired := &link.ExpiredError{
	ExpiresAt: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC),
}
if errors.Is(expired, link.ErrExpired) {
	return link.ErrLinkExpired
}

type Inspection

type Inspection struct {
	// URL is the link without its "signature" and "expires" parameters. Pass
	// it to [Signer.Temporary] or [Signer.Sign] to issue a fresh link.
	// It still contains application paths and query values: do not log it as
	// automatically safe data merely because the signature was removed.
	//
	// Example:
	//	fresh, err := signer.Temporary(details.URL, time.Hour)
	//	if err != nil {
	//		return err
	//	}
	//	_ = fresh
	URL string

	// Temporary reports that the link carries an expiry.
	//
	// Example:
	//	logger.InfoContext(ctx, "link policy", "temporary", details.Temporary)
	Temporary bool

	// ExpiresAt is the expiry with second precision. It is the zero time for a
	// permanent link.
	//
	// Example:
	//	if details.Temporary {
	//		logger.InfoContext(ctx, "link expiry", "expires_at", details.ExpiresAt)
	//	}
	ExpiresAt time.Time

	// Expired reports a temporary link whose expiry has passed.
	//
	// Example:
	//	if details.Expired {
	//		return link.ErrLinkExpired
	//	}
	Expired bool

	// Previous reports that a secret from [Builder.Previous] signed the
	// link. Re-issue such links before dropping that secret.
	//
	// Example:
	//	logger.InfoContext(ctx, "link rotation", "previous", details.Previous)
	Previous bool

	// Consumed reports a temporary link that [Signer.Consume] spent. It is
	// always false when the signer has no cache and for permanent links, so
	// false means "not known to be spent", not a guarantee that it is usable.
	//
	// Example:
	//	if details.Consumed {
	//		return link.ErrLinkUsed
	//	}
	Consumed bool
}

Inspection describes an authenticated link, as returned by Signer.Inspect. It is an observation, not an authorization or consumption reservation; another request can consume the link immediately after inspection.

Example:

details, err := signer.Inspect(ctx, rawURL)
if err != nil {
	return err
}
if details.Expired {
	return link.ErrLinkExpired
}

type MiddlewareBuilder

type MiddlewareBuilder struct {
	// contains filtered or unexported fields
}

MiddlewareBuilder configures request binding before middleware publication. Callbacks run once, synchronously in order on one fresh draft; nil entries panic. Retained drafts cannot change the middleware. Binding callbacks are stored, not executed during construction.

Example:

r := router.New()
r.Use(link.Middleware(signer, func(policy *link.MiddlewareBuilder) {
	policy.BindRequest(nil)
}))

func (*MiddlewareBuilder) BindRequest

func (policy *MiddlewareBuilder) BindRequest(derive func(*http.Request) ([]byte, error)) *MiddlewareBuilder

BindRequest makes Middleware verify links signed with Bind, taking the binding from derive for each request. Typically derive reads the account the link names and returns its current password hash or update time.

derive runs before the signature is checked, so everything it reads from the request is untrusted: look the record up by a value from the URL, but do not treat that value as proof of anything. An error from derive rejects the link with ErrLinkRejected, as does a missing record. The returned problem retains the cause; the Rejected event and response body do not contain it. Applications must redact the cause before logging it. Every request is verified with a binding, so BindRequest cannot accept links signed without Bind. A nil derive function disables request binding and verifies unbound links. The function and its captures remain shared and run only at request time.

Example:

r := router.New()
r.Use(link.Middleware(signer, 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
	})
}))

type MiddlewareBuilderFunc

type MiddlewareBuilderFunc = func(*MiddlewareBuilder)

MiddlewareBuilderFunc edits request-verification settings once in order on a fresh draft. Nil entries panic; empty expansions omit edits. Request-binding functions are retained for requests and are not called during construction.

Example:

var configure link.MiddlewareBuilderFunc = func(policy *link.MiddlewareBuilder) {
	policy.BindRequest(loadBinding)
}
r.With(link.Middleware(signer, configure)).Get("/reset", showResetForm)

type RejectedEvent

type RejectedEvent struct {
	// Method is the client-controlled HTTP method, not a route or identity.
	// Applications must not place secrets in custom method names.
	//
	// Example:
	//	logger.InfoContext(ctx, "link rejected", "method", rejected.Method)
	Method string
}

RejectedEvent reports invalid link admission without the URL, path, binding, or cause. Paths and callback errors can contain credentials. The middleware's returned problem retains the cause; listeners do not receive it as payload.

Example:

unsubscribe, err := events.Listen(link.Rejected, func(ctx context.Context, rejected link.RejectedEvent) error {
	logger.InfoContext(ctx, "link rejected", "method", rejected.Method)
	return nil
})
if err != nil {
	return err
}
defer unsubscribe()

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer signs and verifies application links with an immutable key set. Construct it once and share it across requests. Cache and event services remain live dependencies; verification alone never reserves a link's single use.

Example:

signer, err := link.New(secret)
if err != nil {
	return err
}
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
	return err
}
if err := signer.Verify(signed); err != nil {
	return err
}

func New

func New(secret key.Shared, configure ...BuilderFunc) (*Signer, error)

New creates a signer from the required secret and the configuration built by applying construction callbacks directly once, synchronously in order on one fresh draft. Nil entries panic; expanded nil/empty slices supply no callbacks. Validation follows all callbacks, and retained drafts cannot change the signer.

secret creates all new signatures. Load a validated key.Shared through the key package. New rejects a zero current or previous secret with ErrInvalidSecret. HKDF-SHA256 derives purpose-bound MAC keys without retaining the original secret. Provision separate material for unrelated purposes.

An empty origin yields a relative signer that signs path-and-query values only; otherwise the origin must be an HTTP or HTTPS origin without a path, query, fragment, or user info (an origin's root slash is also accepted). Owned key material and query-name slices are detached. Cache and event buses remain shared live services; callback captures are not cloned. Configure those dependencies before sharing the signer for concurrent use.

Example:

signer, err := link.New(
	secret,
	func(signer *link.Builder) {
		signer.Origin("https://app.example.com")
	},
)
if err != nil {
	return err
}
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
	return err
}
_ = signed
Example

ExampleNew shows construction with an absolute origin, followed by signing and verifying an application link.

package main

import (
	"errors"
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(
		secret,
		func(signer *link.Builder) {
			signer.Origin("https://app.example.com")
		},
	)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Sign("/unsubscribe?user=42")

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed))

	// An absolute signer rejects other origins.
	_, err = signer.Sign("https://evil.example.com/unsubscribe")
	fmt.Println(errors.Is(err, link.ErrInvalidURL))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
true
Example (IgnoreQuery)

ExampleNew_ignoreQuery shows excluding tracking parameters from the signature while retaining verification of application parameters.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(
		secret,
		func(signer *link.Builder) {
			signer.IgnoreQuery("utm_source")
		},
	)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Sign("/unsubscribe?user=42")

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed + "&utm_source=newsletter"))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
Example (InvalidSecret)

ExampleNew_invalidSecret shows how callers inspect the construction error returned for an undersized signing secret.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

func main() {
	_, err := link.New(key.Shared{})
	fmt.Println(err)
}
Output:
link signing secret is invalid
Example (Relative)

ExampleNew_relative shows that an origin-free signer produces relative links suitable for applications that supply their own host.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Sign("/files/report.pdf")

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed))

	// A relative signer accepts paths only.
	_, err = signer.Sign("https://app.example.com/files/report.pdf")
	fmt.Println(err)
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
link URL is invalid
Example (Rotation)

ExampleNew_rotation shows accepting links signed with a previous secret while new links use the current secret.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	previous, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := previous.Sign("/unsubscribe?user=42")

	if err != nil {
		fmt.Println(err)

		return
	}

	replacement, err := key.NewShared([]byte("fedcba9876543210fedcba9876543210"))

	if err != nil {
		fmt.Println(err)

		return
	}

	rotated, err := link.New(
		replacement,
		func(signer *link.Builder) {
			signer.Previous(secret)
		},
	)

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(rotated.Verify(signed))

	// Once the old secret is dropped, its links stop verifying.
	retired, err := link.New(replacement)

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(retired.Verify(signed))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
link signature is invalid

func (*Signer) Consume

func (signer *Signer) Consume(ctx context.Context, rawURL string, opts ...CallOption) error

Consume verifies rawURL and spends it, so no later Consume of the same link succeeds. Use it for single-use links such as a password reset: show the form for any valid link, then call Consume in the handler that applies the change.

Consume verifies the signature, the binding (Bind), and the expiry exactly like Signer.Verify, then records the link in the signer's cache with an atomic add, so concurrent calls on one link have exactly one winner, even across servers sharing a cache driver. It returns:

  • ErrInvalidURL when rawURL cannot be parsed, before checking the cache;
  • ErrNoCache for a parsed URL when the signer has no cache, before verifying;
  • ErrInvalidURL, ErrInvalidSignature, or an *ExpiredError on verification;
  • ErrNotTemporary for a permanent link;
  • ErrConsumed when the link was already spent;
  • a wrapped cache error when consumption could not be confirmed. The marker may already have been committed, so the caller must not proceed or assume a retry can succeed. Offer a new link when recovery cannot establish use.

With Builder.Events, a successful call publishes Consumed; failures and repeated calls publish nothing.

Consume before committing the side effect. That gives at-most-once behavior: if the side effect fails afterwards the link stays spent and the user must request a new one. Consuming after the side effect would allow a crash or a concurrent request to apply it twice.

The cache entry lives until the link's expiry plus one minute for clock skew between servers, so the cache holds one small key per consumed link for the link's remaining lifetime. A cache that loses entries early, such as an evicting or restarted in-memory cache, lets a spent link work again.

Example:

err := signer.Consume(ctx, rawURL, link.Bind(user.PasswordHash))
if errors.Is(err, link.ErrConsumed) {
	return link.ErrLinkUsed.Derive(func(details *problem.Builder) {
		details.Error(err)
	})
}
if err != nil {
	return err
}
Example

ExampleSigner_Consume shows using a cache-backed signer to accept a temporary link only once and report later reuse.

signer, err := link.New(
	secret,
	func(signer *link.Builder) {
		signer.Cache(cache.New(memorycache.New()))
	},
)

if err != nil {
	fmt.Println(err)

	return
}

signed, err := signer.Temporary("/reset?user=42", time.Hour)

if err != nil {
	fmt.Println(err)

	return
}

fmt.Println(signer.Consume(context.Background(), signed))

err = signer.Consume(context.Background(), signed)
fmt.Println(errors.Is(err, link.ErrConsumed))
Output:
<nil>
true

func (*Signer) ConsumeRequest

func (signer *Signer) ConsumeRequest(request *http.Request, opts ...CallOption) error

ConsumeRequest consumes the link a request was made to, using the same URL reconstruction as Middleware (the request path and query, completed with the signer's origin), and the request's context. Handlers use it so they do not have to rebuild the URL. See Signer.Consume.

Example:

if err := signer.ConsumeRequest(r, link.Bind(user.PasswordHash)); err != nil {
	return err
}

func (*Signer) ConsumeURL

func (signer *Signer) ConsumeURL(ctx context.Context, value *url.URL, opts ...CallOption) error

ConsumeURL consumes value without mutating it. value is a parsed signed URL, typically returned by TemporaryURL. It checks cache configuration before URL validation; nil value with a configured cache returns ErrInvalidURL. Other errors and uncertain-write behavior follow Signer.Consume.

Example:

if err := signer.ConsumeURL(ctx, signedURL, link.Bind(user.PasswordHash)); err != nil {
	return err
}

func (*Signer) Inspect

func (signer *Signer) Inspect(ctx context.Context, rawURL string, opts ...CallOption) (Inspection, error)

Inspect authenticates rawURL and describes it. Use it to decide what to show for a link without acting on it: offer a new link for an expired reset, or re-issue links signed with a previous secret.

Inspect checks the signature and binding (Bind) like Signer.Verify, but an expired link is not an error: it returns the details with Inspection.Expired set. Parsing or signer-mode validation returns ErrInvalidURL; malformed signature data, forgery, or a wrong binding returns ErrInvalidSignature. Every failure returns zero details, so unauthenticated input never reveals expiry or previous-key metadata. With a cache (Builder.Cache), Inspection.Consumed reports whether the link was spent, and a cache failure returns a wrapped error. Inspect never changes the link's state.

Example:

details, err := signer.Inspect(ctx, rawURL)
if err == nil && details.Expired {
	// Offer to send a new link for details.URL.
}
Example

ExampleSigner_Inspect shows reading verified link metadata without exposing the signature in the resulting application URL.

signer, err := link.New(secret)

if err != nil {
	fmt.Println(err)

	return
}

signed, err := signer.Temporary("/reset?user=42", time.Hour)

if err != nil {
	fmt.Println(err)

	return
}

details, err := signer.Inspect(context.Background(), signed)

if err != nil {
	fmt.Println(err)

	return
}

fmt.Println(err, details.URL, details.Temporary, details.Expired)
Output:
<nil> /reset?user=42 true false

func (*Signer) InspectURL

func (signer *Signer) InspectURL(ctx context.Context, value *url.URL, opts ...CallOption) (Inspection, error)

InspectURL describes value without mutating it. nil or an unsuitable signer target returns ErrInvalidURL. Signature and cache failures follow Signer.Inspect and always return zero details, not a partially authenticated inspection.

Example:

details, err := signer.InspectURL(ctx, signedURL)
if err != nil {
	return err
}
if details.Consumed {
	return link.ErrLinkUsed
}

func (*Signer) Sign

func (signer *Signer) Sign(rawURL string, opts ...CallOption) (string, error)

Sign signs rawURL as a permanent link and returns its canonical URL string. A reserved query parameter already present in rawURL is replaced. Pass Bind to cover server-side state in the signature.

Example:

signed, err := signer.Sign("/files/report.pdf")
if err != nil {
	return err
}
_ = signed

func (*Signer) SignURL

func (signer *Signer) SignURL(value *url.URL, opts ...CallOption) (*url.URL, error)

SignURL signs value as a permanent link without mutating value. See Signer.Sign. nil or a target outside the signer's mode returns ErrInvalidURL. The returned URL is an independent value; it does not grant protection to ignored parameters.

Example:

signed, err := signer.SignURL(&url.URL{
	Path: "/files/report.pdf",
})
if err != nil {
	return err
}
_ = signed.String()

func (*Signer) Temporary

func (signer *Signer) Temporary(rawURL string, ttl time.Duration, opts ...CallOption) (string, error)

Temporary signs rawURL as a temporary link that expires ttl from now. A ttl that is not positive returns ErrInvalidExpiry. Pass Bind to cover server-side state in the signature.

Example:

signed, err := signer.Temporary("/files/report.pdf", 15*time.Minute)
if err != nil {
	return err
}
_ = signed
Example

ExampleSigner_Temporary shows creating and verifying an expiring link, then checking the error returned for a nonpositive lifetime.

package main

import (
	"errors"
	"fmt"
	"time"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Temporary("/reset?user=42", 15*time.Minute)

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed))

	_, err = signer.Temporary("/reset", 0)
	fmt.Println(errors.Is(err, link.ErrInvalidExpiry))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
true

func (*Signer) TemporaryURL

func (signer *Signer) TemporaryURL(value *url.URL, ttl time.Duration, opts ...CallOption) (*url.URL, error)

TemporaryURL signs value as a temporary link that expires ttl from now, without mutating value. A ttl that is not positive returns ErrInvalidExpiry. The expiry has second precision and is authenticated with the rest of the URL.

Example:

signed, err := signer.TemporaryURL(&url.URL{
	Path: "/reset",
}, 15*time.Minute)
if err != nil {
	return err
}
_ = signed.String()

func (*Signer) Verify

func (signer *Signer) Verify(rawURL string, opts ...CallOption) error

Verify verifies rawURL's signature and, when present, its expiry. Pass Bind with the value the link was signed with, or a bound link returns ErrInvalidSignature. Verify does not know whether a link was consumed; see Signer.Consume and Signer.Inspect. Parse/target failures return ErrInvalidURL, authentication failures return ErrInvalidSignature, and authenticated expiry returns *ExpiredError.

Example:

if err := signer.Verify(rawURL, link.Bind(user.PasswordHash)); err != nil {
	return err
}
Example

ExampleSigner_Verify shows that verification accepts an intact signed link and rejects a link whose application parameters changed.

package main

import (
	"fmt"

	"arkoren.dev/core/http/link"
	"arkoren.dev/foundation/key"
)

// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))

func main() {
	signer, err := link.New(secret)

	if err != nil {
		fmt.Println(err)

		return
	}

	signed, err := signer.Sign("/download?file=1")

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(signer.Verify(signed))

	// Changing any signed part invalidates the link.
	fmt.Println(signer.Verify("/download?file=2&" + signed[len("/download?file=1&"):]))
}

// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
	secret, _ := key.NewShared(raw)

	return secret
}
Output:
<nil>
link signature is invalid

func (*Signer) VerifyURL

func (signer *Signer) VerifyURL(value *url.URL, opts ...CallOption) error

VerifyURL verifies value's signature and, when present, its expiry. The expiry is checked only after the signature, so a forged link never reports ErrExpired. value is never mutated; nil or an unsuitable target returns ErrInvalidURL.

Example:

if err := signer.VerifyURL(signedURL, link.Bind(user.PasswordHash)); err != nil {
	return err
}

Directories

Path Synopsis
internal
signature
Package signature owns the versioned link MAC format, not link policy.
Package signature owns the versioned link MAC format, not link policy.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL