A reader clicks Login halfway through an article, authenticates, and lands on the dashboard instead of the paragraph they cared about. That tiny bit of friction is surprisingly irritating. WordPress already knows how to carry a return URL through its login form—we only need to generate and render it without confusing login state with account registration.

The compact template solution

template-parts/account-link.phpphp
<?php
if ( ! is_user_logged_in() && ! is_page( "register" ) ) {
    $return_url = is_singular() ? get_permalink() : home_url( "/" );
    $login_url  = wp_login_url( $return_url );
    ?>
    <p class="account-prompt">
        <?php esc_html_e( "Already have an account?", "lynxbee" ); ?>
        <a href="<?php echo esc_url( $login_url ); ?>">
            <?php esc_html_e( "Log in", "lynxbee" ); ?>
        </a>
    </p>
    <?php
}

Every helper has a narrow job

  • is_user_logged_in() evaluates the current authentication cookie and user state.

  • is_page("register") suppresses the prompt on the page whose slug is register; a numeric page ID is safer if editors may change slugs.

  • is_singular() ensures get_permalink() refers to the viewed post, page, or custom post rather than guessing on archives and search pages.

  • wp_login_url($return_url) adds the encoded redirect_to value to WordPress’s configured login URL.

  • esc_url() escapes the URL at HTML output time.

  • esc_html_e() translates and escapes visible text using the theme or plugin text domain.

A reusable shortcode instead of a header edit

wp-content/plugins/site-account-link/site-account-link.phpphp
<?php
/* Plugin Name: Site Account Link */
 
function lynxbee_account_link_shortcode(): string {
    if ( is_user_logged_in() || is_page( "register" ) ) {
        return "";
    }
 
    $return_url = is_singular() ? get_permalink() : home_url( "/" );
    $login_url  = wp_login_url( $return_url );
 
    return sprintf(
        '<p class="account-prompt">%1$s <a href="%2$s">%3$s</a></p>',
        esc_html__( "Already have an account?", "lynxbee" ),
        esc_url( $login_url ),
        esc_html__( "Log in", "lynxbee" )
    );
}
add_shortcode( "account_login_link", "lynxbee_account_link_shortcode" );

The shortcode is portable and returns markup

  • Place [account_login_link] in content or a compatible block/widget area after activating the plugin.

  • A shortcode callback returns its output; echoing can move markup to an unexpected location.

  • The return type makes the empty logged-in path explicit.

  • sprintf keeps the HTML structure fixed while each dynamic value is escaped for its exact context.

  • Prefix names to avoid collisions with themes and other plugins; use namespaces for a larger plugin.

  • A shortcode is still evaluated server-side, so page-cache variation remains relevant.

template-parts/account-link.phpphp
<?php
$return_url = is_singular() ? get_permalink() : home_url( "/" );
 
if ( is_user_logged_in() ) : ?>
    <a href="<?php echo esc_url( wp_logout_url( $return_url ) ); ?>">
        <?php esc_html_e( "Log out", "lynxbee" ); ?>
    </a>
<?php else : ?>
    <a href="<?php echo esc_url( wp_login_url( $return_url ) ); ?>">
        <?php esc_html_e( "Log in", "lynxbee" ); ?>
    </a>
<?php endif; ?>

Let WordPress create the logout nonce

  • wp_logout_url() generates the logout action URL and its nonce; do not hard-code /wp-login.php?action=logout.

  • Passing the same local return URL restores the current content after logout.

  • Escape both generated URLs when placing them in href.

  • Do not display usernames or profile values without context-appropriate escaping.

Protect a page instead of merely showing a prompt

A conditional link is presentation, not access control. If the content itself requires authentication, perform the decision before template output and use WordPress’s authentication flow. A private plugin can hook template_redirect, check the target route, call auth_redirect(), and return immediately. Do not rely on hiding HTML that the server already sent.

site-access.phpphp
function lynxbee_require_login_for_member_page(): void {
    if ( is_page( "members" ) && ! is_user_logged_in() ) {
        auth_redirect();
        exit;
    }
}
add_action( "template_redirect", "lynxbee_require_login_for_member_page" );

Redirect before headers are sent

  • template_redirect runs after WordPress knows the queried page and before the template renders.

  • auth_redirect() sends unauthenticated visitors through the core login flow and handles the requested destination.

  • exit prevents protected template code from continuing after a redirect response.

  • Use capability checks such as current_user_can() when access depends on a role or permission rather than any account.

  • Do not protect REST, AJAX, feeds, or downloads accidentally; define every route and response type in scope.

Avoid an open redirect

  • Pass a server-derived local permalink or home URL to wp_login_url().

  • Do not copy an arbitrary redirect_to query parameter into a custom redirect without validation.

  • When implementing your own post-login redirect, validate the destination with wp_validate_redirect() or perform it with wp_safe_redirect().

  • Call exit after wp_safe_redirect() because the function does not terminate PHP execution.

  • Only allow another host through allowed_redirect_hosts when the cross-domain flow is intentional and reviewed.

A full-page cache may store the anonymous HTML and serve it to a logged-in visitor, or cache a personalized page and expose it to someone else if cookie variation is misconfigured. WordPress normally sends logged-in cookies, but the CDN or cache plugin must actually bypass or vary on them.

  • Test while logged out and logged in through the same CDN path, not only by bypassing cache locally.

  • Configure the cache to bypass authenticated WordPress cookies and all login/account pages.

  • Purge affected pages after changing the template or shortcode.

  • For heavily cached public pages, render only generic account navigation or use a carefully designed client-side fragment backed by a non-cacheable identity endpoint.

  • Never embed private user data in a response that a shared cache may store.

Verify the complete flow

  1. Open a normal post in a private window and confirm the login link appears.

  2. Inspect the link and confirm its redirect_to resolves to the same site and intended post.

  3. Log in with a test account and confirm WordPress returns to that post.

  4. Refresh and confirm the logged-out prompt disappears.

  5. Log out and confirm the nonce-backed flow returns to the post.

  6. Visit the excluded registration page and confirm no circular or redundant prompt appears.

  7. Test the homepage, archive, search result, 404, preview, and multilingual/domain-mapped variants.

  8. Repeat through the production cache/CDN and on mobile.

Common mistakes and fixes

  • Hard-coded login domain: use wp_login_url() so staging, HTTPS, subdirectories, and multisite configuration are respected.

  • Raw URL printed into HTML: escape at output with esc_url().

  • “Registered user” inferred from login state: describe the visitor as logged in or logged out instead.

  • Prompt hidden but content still visible: enforce authorization before rendering.

  • Redirect returns to the wrong item: call get_permalink() in a valid singular query context and choose an explicit fallback.

  • Prompt appears on the login/register page: exclude by stable page ID, template, or route to prevent a confusing loop.

  • Logged-in users see the login prompt: audit shared-cache cookie variation and purge stale HTML.

  • Theme update removes the code: move it to a child theme or site plugin.

Official WordPress references