benecaster_signed_link_base_url
Filters the landing URL an emailed signed link points at, before the token query argument is appended. Defaults to the show’s account page, falling back to the site home page when no account page is configured — never an empty string, because a link with no host is a broken email rather than a refused one.
⚠ This only decides what the recipient SEES. The token is consumed by SignedLinkEndpoint on init wherever it lands, so redirecting elsewhere does not skip validation or sign-in.
⚠ Return an absolute URL on this site. wp_safe_redirect() refuses an off-site host and drops the recipient on the home page instead — which looks like a broken link to them and gives you nothing in the logs.
Customise the emailed signed link — lifetime, landing page, and what happens on click
Benecaster mails single-use signed links in two places today: the welcome email a new follower receives carries a Set your password link, and the you already follow this show mail carries a login link. Both are minted by Benecaster\Support\SignedLink — HMAC-SHA256 over user_id|show_id|purpose|expiry|jti, keyed on wp_salt( 'auth' ), spent exactly once.
If you are adding your own emailed link, widen this class — do not mint a second scheme. A second scheme is a second place to get expiry, replay and secret rotation wrong. The payload already carries a purpose discriminator and a show_id, so a new kind of link fits without a parallel implementation. This warning is written from experience: the roadmap for this feature named an existing {{episode_magic_link}} scheme to reuse, that scheme had never been built, and only a repo-wide grep caught it before a fork happened by accident.
Rotating the site’s salts revokes every outstanding link. That is deliberate — it is the behaviour a site wants the day it suspects its database was read — but it means a salt rotation will generate support mail from followers whose link stopped working. Tell them to submit the follow form again.
Do not use SignedLink as a general-purpose authentication token. It proves the holder received a specific email, nothing more. A link forwarded to someone else works for whoever opens it first — the same trade-off every emailed magic link makes. Anything that must know who is at the keyboard needs a real login session.
Telling the visitor a link did not work. A refused link — expired, already used, tampered with, or belonging to a deleted account — redirects to the account page (or the home page) with benecaster_link_expired=1 appended. Core prints nothing for it; the query argument exists so a theme can. Say only that the link no longer works, and do not try to explain why: core deliberately gives every refusal the same destination, because distinguishing “expired” from “already used” from “not a real link” tells someone holding a stolen or guessed token which part of it to work on. The visitor arriving with that flag is not logged in, so do not render anything that assumes a session.
Adding a new KIND of link — widen the payload, do not fork the class. If your link needs to carry something else, an episode id or 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 what makes this safe to ship. 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 every one of them at the wrong data, invalidating every outstanding link at once, and nobody will connect the support mail to the release that caused it. A field you append is inside the signature like every other, so tolerating unknown fields opens nothing.
Two more things decide whether your link fits with no change at all. Single use is the caller’s choice, not the scheme’s — call consume() for a one-shot link, verify() for one the recipient may open repeatedly for its whole life. And entitlement is not this class’s job: a valid signature proves we sent the link, never that the recipient may still have the thing it points at.
The cost to weigh before you start: every issued link writes a row into the recipient’s user meta (the jti, for single-use enforcement). 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, the answer is a stateless mode on this class (skip jti registration, accept replay within the TTL), not a parallel implementation that re-derives the signing and expiry logic and gets one of them subtly wrong.
Telling a passwordless arrival apart from an ordinary login. A follower who clicks the emailed link is signed in without ever typing a password, and WordPress’s wp_login fires for them exactly as it does for someone who used the login form. benecaster_signed_link_logged_in fires alongside it when — and only when — the session came from a signed link.
Do not use that hook to perform anything the arriving visitor did not ask for. Logging someone in from a GET request is a login CSRF by construction — inherent to every magic link, not a defect here — so an attacker can cause the hook to fire for a victim’s browser. Recording a flag or showing a notice is fine. Charging something, sending mail, or deleting anything is not. An account that can edit_posts is never signed in by a link at all, so the hook never fires for one; those accounts are sent to the WordPress password-reset screen instead, because a reset invalidates the old password and therefore leaves evidence, while a silent auto-login leaves none. There is deliberately no filter to change that.
<?php
/**
* Shorten the life of emailed account links from one week to 48 hours.
*
* ⚠ Shorter is safer, but not free: the link lives in a mailbox, and a
* follower who submits the form on a Friday evening and opens their mail on
* Monday must still be able to click it. Do not go below a day without a
* reason — an expired link costs you the signup, not just the click.
*
* @param int $ttl Seconds. Default WEEK_IN_SECONDS.
* @param string $purpose One of SignedLink::PURPOSE_*.
*/
add_filter( 'benecaster_signed_link_ttl', function ( int $ttl, string $purpose ): int {
return \Benecaster\Support\SignedLink::PURPOSE_ACCOUNT_ACCESS === $purpose
? 2 * DAY_IN_SECONDS
: $ttl;
}, 10, 2 );
/**
* Land followers on a custom members page instead of the account page.
*
* ⚠ Return an absolute URL on this site. The link is consumed by
* SignedLinkEndpoint on `init` wherever it lands, so the destination only
* decides what the follower SEES — but wp_safe_redirect() will refuse an
* off-site host and drop them on the home page instead.
*/
add_filter( 'benecaster_signed_link_base_url', function ( string $url, string $purpose, int $show_id ): string {
$page = get_page_by_path( 'members' );
return $page instanceof WP_Post ? get_permalink( $page ) : $url;
}, 10, 3 );
/**
* Do something once, the first and only time a link is clicked.
*
* ⚠ Fires AFTER the token is spent, so a replay never reaches this — which
* is exactly why it is safe to do something non-idempotent here. Firing it
* before consumption would let anyone with a copy of the URL run it twice.
*/
add_action( 'benecaster_signed_link_consumed', function ( int $user_id, string $purpose, int $show_id ): void {
if ( \Benecaster\Support\SignedLink::PURPOSE_ACCOUNT_ACCESS !== $purpose ) {
return;
}
update_user_meta( $user_id, '_my_addon_confirmed_email_at', time() );
}, 10, 3 );
/**
* Show a friendly notice when someone arrives from a dead link.
*
* ⚠ Say only that the link no longer works. Do NOT try to explain WHY —
* core deliberately gives every refusal the same destination, because
* distinguishing "expired" from "already used" from "not a real link"
* tells someone holding a stolen or guessed token which part of it to work
* on. Reading this flag and printing four different messages would rebuild
* exactly the oracle the single destination removes.
*/
add_action( 'benecaster_before_account', function (): void {
if ( empty( $_GET['benecaster_link_expired'] ) ) {
return;
}
printf(
'<p class="notice">%s</p>',
esc_html__( 'That link has expired or has already been used. Sign in, or use the follow form again to get a new one.', 'my-theme' )
);
} );
/**
* Nudge passwordless arrivals towards setting a password, once.
*
* ⚠ Fires IN ADDITION to wp_login, not instead of it. A listener hooked to
* both will run twice for one arrival — pick one.
*/
add_action( 'benecaster_signed_link_logged_in', function ( int $user_id, string $purpose ): void {
if ( get_user_meta( $user_id, '_my_addon_password_nudged', true ) ) {
return;
}
update_user_meta( $user_id, '_my_addon_password_nudged', 1 );
set_transient( 'my_addon_nudge_' . $user_id, 1, HOUR_IN_SECONDS );
}, 10, 2 );
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
$url |
string |
— | The landing URL. Account page, or the home page as a fallback. |
$purpose |
string |
— | One of the `SignedLink::PURPOSE_*` constants. |
$show_id |
int |
— | Show context, `0` when the link is not show-scoped. |
Returns:
string
Examples
Land followers on a custom members page
add_filter( 'benecaster_signed_link_base_url', function ( string $url, string $purpose, int $show_id ): string {
$page = get_page_by_path( 'members' );
return $page instanceof WP_Post ? get_permalink( $page ) : $url;
}, 10, 3 );
Notes
A refused link — expired, already spent, tampered with, or belonging to a deleted account — is redirected to this URL (or the home page) with benecaster_link_expired=1 appended. Core prints nothing for that argument; it exists so a theme can.
⚠ A visitor arriving with benecaster_link_expired=1 is not logged in, so a custom landing page must not assume a session. And say only that the link no longer works: core deliberately gives every kind of refusal the same destination, because distinguishing "expired" from "already used" from "not a real link" tells someone holding a stolen or guessed token which part of it to work on.
Need this built rather than just documented? See our services →