Skip to main content

benecaster_signed_link_logged_in

Action Free

Fires after a subscriber has been signed in by clicking an emailed link rather than by entering a password.

It fires in addition to WordPress’s own wp_login, not instead of it. Core deliberately fires wp_login too, so security logs, last-seen timestamps and MFA integrations all see a real login — skipping it would make this a login half the site never learns about. But wp_login cannot tell a passwordless arrival from an ordinary one, and this hook can. ⚠ A listener hooked to both runs twice for one arrival; pick one.

The elevated-account guard is a feature, not an edge case. An account that can edit_posts is never signed in by an emailed link, so this hook never fires for one — those accounts get the WordPress password-reset screen instead. An emailed link is a bearer credential: whoever opens a forwarded message is who gets in, which is an acceptable trade for a follower reaching their own feed URL and not acceptable for wp-admin. The reset path also leaves evidence, because setting a password invalidates the old one, where a silent auto-login leaves none. There is deliberately no filter to lift this, and a podcaster testing their own follow form from an admin address will hit it and may conclude the feature is broken.

Customise the emailed signed link — lifetime, landing page, and what happens on click

Free Intermediate

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 );

View on GitHub →

Parameters

Name Type Default Description
$user_id int The subscriber who was signed in.
$purpose string One of the `SignedLink::PURPOSE_*` constants.

Examples

Nudge passwordless arrivals to set a password, once

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 );

Notes

⚠⚠ Do not use this hook to perform anything the arriving visitor did not ask for. Logging someone in from a GET request is a login CSRF by construction — that is inherent to every magic link, not a defect here — so an attacker can cause this 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.

Any existing session is cleared first. Someone may already be signed in as a different user — a shared machine at home is the ordinary case — and the link is proof of mailbox ownership for the account it names, so switching is correct.

Need this built rather than just documented? See our services →