SignedLink
\Benecaster\Support\SignedLink
Mints and validates the HMAC-SHA256 signed, time-limited, single-use links Benecaster emails to subscribers. Two mails carry one today: the welcome email a new follower receives (a Set your password link) and the you already follow this show mail (a login link). Both land the recipient on their account page, signed in, without a password ever being typed.
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.
The warning is written from experience. The roadmap for this feature named an existing {{episode_magic_link}} HMAC scheme as machinery to reuse. It does not exist — it is an unbuilt Email Editor merge tag, described only in the add-on roadmap — and only a repo-wide grep caught that before a fork happened by accident. When the Email Editor add-on builds its episode magic links, this is the class it should widen.
Adding a new kind of link — widen the payload, do not fork. The payload is user_id|show_id|purpose|expiry|jti, base64url-encoded and signed as one blob. If your link must carry something else — an episode id, an order id — append it as a sixth 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 by the release before it. Append, never insert. The first five 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 30-day magic link in an episode notification never gets consumed and simply ages out of the jti registry.
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. If that is too expensive for your case, the answer is a stateless mode on this class (skip jti registration, accept replay within the TTL), not a parallel implementation.
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
issue( int $user_id, string $purpose, int $show_id = 0, ?int $ttl = null ): string |
Public | — | Mints a signed URL for one user and records its jti. Returns an absolute URL, or '' when the arguments are unusable (no user, no purpose, or a non-positive TTL). Passing $ttl explicitly bypasses the benecaster_signed_link_ttl filter. |
verify( string $token ): ?array |
Public | — | Validates signature, expiry and single-use status without spending the link, returning the payload or null. 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. |
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. |
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.
See also
SignedLinkEndpoint, which consumes the token on
init and performs the sign-in.