Skip to main content

SignedLink

\Benecaster\Support\SignedLink

Class Free

Mints and validates the HMAC-SHA256 signed, time-limited links Benecaster emails to subscribers. Two mails carry a single-use login link: the welcome email a new follower receives (a Set your password link) and the you already follow this show mail. Both land the recipient on their account page, signed in, without a password ever being typed. The third kind is the episode magic link from benecaster_get_episode_access_url(), which opens one episode and signs nobody in.

If you are adding your own emailed link, widen this class — do not mint a second scheme. That is the single most important thing on this page. A second scheme is a second place to get expiry, replay and secret rotation wrong, and only one of the two will get fixed when one of them does. The payload already carries a purpose discriminator and a show_id, so a new kind of link fits without a parallel implementation.

Episode magic links are built on this class. They use the purpose episode_access, carry the episode ID as an appended sixth field, travel in their own query argument, and are stateless: no jti is recorded, so a subscriber can open one again until it expires. Only the episode-access handler reads them, through verify_stateless(), and it opens that one episode without logging anyone in. verify() and consume() refuse them, so an episode link can never reach the login handler.

Adding a new kind of link — widen the payload, do not fork. The payload is user_id|show_id|purpose|expiry|jti|episode_id, base64url-encoded and signed as one blob (episode_id is 0 on links that are not for an episode). If your link must carry something else, such as an order id, append it as a further field and read it back positionally. parse() deliberately accepts at least five fields and ignores ones it does not know, which is precisely what makes that safe: the release that adds the field still honours every link minted before it. Append, never insert. The first six positions are load-bearing for links already sitting in people’s mailboxes; inserting a field in the middle re-points all of them at the wrong data and invalidates every outstanding link at once — a failure nobody will connect to the release that caused it. A field you append is inside the signature like every other, so tolerating unknown fields opens nothing.

Single use is the caller’s choice, not the scheme’s. Call consume() for a one-shot link. Call verify() for one the recipient may open repeatedly for its whole life. A link sent in bulk and opened repeatedly is better stateless, as the episode magic link is.

Entitlement is not this class’s job. A valid signature proves Benecaster sent the link. It does not prove the recipient may still have the thing it points at — a subscriber who cancelled yesterday still holds a perfectly valid link from last week. Check entitlement separately, at the point of use.

Do not use SignedLink as a general-purpose authentication token. It proves the holder received a specific email, nothing more. A forwarded link works for whoever opens it first — the trade-off every emailed magic link makes. Anything that must know who is at the keyboard needs a real login session.

The cost to weigh before you build on it: every issued link writes a row into the recipient’s user meta. That is nothing for a signup link and material for a per-subscriber episode notification — five thousand subscribers is five thousand meta writes per send, held for the link’s lifetime. That is why the episode magic link is stateless (no jti, replayable within its lifetime, the forwarded-mail trade-off). Statelessness belongs to the signed purpose, never to the caller, so a single-use login link can never be read the stateless way and replayed.

Methods

Method Visibility Since Description
issue( int $user_id, string $purpose, int $show_id = 0, ?int $ttl = null, int $episode_id = 0 ): string Public — Mints a signed URL for one user and records its jti (not for an episode link, which is stateless). Returns an absolute URL, or '' when the arguments are unusable (no user, no purpose, a non-positive TTL, or an episode link with no $episode_id). Passing $ttl explicitly bypasses the benecaster_signed_link_ttl filter. An episode link uses EPISODE_QUERY_ARG; prefer benecaster_get_episode_access_url() for one.
mint( int $user_id, string $purpose, int $show_id = 0, ?int $ttl = null, int $episode_id = 0 ): string Public — Mints the bare token issue() puts in a URL, with the same rules. Returns '' when the arguments are unusable.
verify_stateless( string $token, string $purpose ): ?array Public — Validates a stateless token (today only episode_access): signature, expiry and purpose, with no jti lookup, so it can be checked again until it expires. Returns the payload, including episode_id, or null. Refuses any purpose that is not stateless. A good signature proves only that Benecaster sent the link; whether the recipient may still have the episode is the caller's check.
verify( string $token ): ?array Public — Validates signature, expiry and single-use status without spending the link, returning the payload or null. Refuses a stateless purpose such as episode_access. Use when you need to know whether a link is good but are not yet acting on it. Anything that grants access must call consume() instead — a verify-then-act pair is a replay window.
consume( string $token ): ?array Public — Validates a token and spends it, returning the payload once and only once. The jti is deleted, so a replay of the same URL returns null. Refuses a stateless purpose such as episode_access.

Constants

Name Value Description
QUERY_ARG 'benecaster_link' The query argument carrying the whole opaque token. One argument, not five, so the payload and its signature can never be separated in transit.
PURPOSE_ACCOUNT_ACCESS 'account_access' Account access — both the set your password link on a new follower's welcome mail and the log in link on the already following mail. One purpose, two pieces of copy, deliberately: the mails differ in what they say, not in what the link does. Splitting the purpose would create two code paths that must then be kept identical forever.
PURPOSE_EPISODE_ACCESS 'episode_access' An episode magic link: opens one episode for one subscriber without a login. Stateless — never registered or spent — and readable only through verify_stateless(). Default lifetime 30 days.
EPISODE_QUERY_ARG 'benecaster_episode_link' The query argument carrying an episode magic link's token. Separate from QUERY_ARG, so the login handler never sees an episode link.
JTI_META '_benecaster_signed_link_jti' User meta holding unspent jti values as [ jti => expiry timestamp ]. Pruned of expired entries on every read and every write. Per-user data — eligible for GDPR erasure.

Hooks Fired

Notes

Design decisions, each load-bearing.

The secret is wp_salt( 'auth' ), not a stored option. Rotating the site's salts therefore revokes every outstanding link — the correct behaviour for a credential, and what a site wants the day it suspects its database was read. It also means a salt rotation generates support mail from followers whose link stopped working; tell them to submit the follow form again.

Expiry lives inside the signed payload, not in a separate query argument, so it cannot be extended by editing the URL.

Single use is enforced by the jti, not by the signature. Signature and expiry alone would leave a link replayable for its whole lifetime by anyone who saw the mailbox, a forwarded copy or a proxy log. The signature proves the link was minted by us; only the jti proves it has not already been spent.

Comparison is hash_equals(), never ===.

A refused link is not explained. Expired, already used, tampered with, or belonging to a deleted account all redirect to the same place with benecaster_link_expired=1 appended. Distinguishing them would tell someone holding a stolen or guessed token which part of it to work on. A theme reading that flag should say only that the link no longer works — and must not assume a session, because the visitor arriving with it is not logged in.

Benecaster consumes the token on init and performs the sign-in.