Contents
Overview
This article explains, step by step, how to build secure signed links for custom password-reset flows in WordPress. The approach combines an HMAC signature (so links cannot be forged) with a one-time random token stored (hashed) in usermeta (so a reset link can be single-use). It also covers creating the request page, the reset landing page (shortcode), safe email delivery, and the POST handler that actually updates the password and invalidates sessions.
Why use signed links and a one-time token?
- Signature (HMAC): Proves the links parameters were generated by your server and not tampered with.
- Random token stored hashed: Prevents reuse of a link (single-use). Storing only the hash in the database avoids keeping plaintext tokens.
- Expiration timestamp: Limits the time window an attacker could use a leaked link.
- HTTPS only: Ensure links are delivered and used over TLS in production.
Prerequisites
- WordPress (any modern version).
- Access to themes functions.php or ability to create a small plugin.
- A page in WordPress with the slug custom-password-reset (content will be the shortcode we register).
- Ability to edit wp-config.php to add a dedicated secret key for signing (recommended).
High-level flow
- User requests a password reset (submits email) → server generates a random token, stores its hash expiry in usermeta, builds a signed URL that includes uid, token, expiry and signature → email is sent to the users registered email.
- User clicks the link → landing page verifies signature expiry that the supplied token matches the stored hash → shows form to set a new password.
- User submits new password → POST handler re-verifies everything, sets the password with WordPress API, deletes the stored token, destroys all sessions for the user and redirects to login (or shows success).
Step 0 — Add a strong secret to wp-config.php
Store an application-specific secret in wp-config.php (do not use a short or predictable string). You can use wp-cli or any secure random generator to create the value. This key is used to sign HMACs for links.
lt?php // Put this in wp-config.php before the line that says Thats all, stop editing... if ( ! defined( CUSTOM_PASSWORD_RESET_KEY ) ) { // Replace the string below with a long random value define( CUSTOM_PASSWORD_RESET_KEY, replace-with-a-long-random-string-or-generated-value ) } ?gt
Step 1 — Generation: function to create signed reset links
This function creates a cryptographically-random token, stores a hash of it in usermeta together with an expiry, computes an HMAC signature, and returns a URL pointing at your reset landing page.
lt?php / Generate a signed password-reset link for a user. @param int user_id @param int ttl_seconds Time window in seconds (default 1 hour). @return stringWP_Error URL to send or WP_Error on failure. / function custom_pr_generate_reset_link( user_id, ttl_seconds = 3600 ) { if ( ! user = get_userdata( user_id ) ) { return new WP_Error( invalid_user, User does not exist ) } // Generate a cryptographically-random token (raw token will be emailed to user) token = bin2hex( random_bytes( 16 ) ) // 32 hex chars expires = time() (int) ttl_seconds payload = user_id . . token . . expires // Compute HMAC signature using your secret if ( ! defined( CUSTOM_PASSWORD_RESET_KEY ) empty( CUSTOM_PASSWORD_RESET_KEY ) ) { return new WP_Error( no_secret, Reset key not configured ) } sig = hash_hmac( sha256, payload, CUSTOM_PASSWORD_RESET_KEY ) // Store only the hash of the token in usermeta update_user_meta( user_id, _custom_pr_token_hash, hash( sha256, token ) ) update_user_meta( user_id, _custom_pr_expires, (int) expires ) // Build URL to your reset landing page (page slug: custom-password-reset) url = add_query_arg( array( uid => user_id, token => token, expires => expires, sig => sig, ), home_url( /custom-password-reset/ ) ) return esc_url_raw( url ) } ?gt
Step 2 — Emailing the link (request handler)
When a visitor submits an email address to request a password reset, find the user by email and call the generator above. Always return a neutral response message to the visitor (do not reveal whether the email exists). Optionally implement rate limiting / throttling per IP and per email.
lt?php / Example helper to request a reset by email. Should be called from a form handler that accepts an email address. / function custom_pr_request_reset_by_email( email ) { // Neutral response should be given to visitor regardless of result. neutral_message = If an account with that email exists, you will receive a reset link. if ( empty( email ) ) { return new WP_Error( invalid, neutral_message ) } user = get_user_by( email, email ) // Always return the neutral message to avoid user enumeration. if ( ! user ) { return neutral_message } // Optional: implement throttling/transient checks here link = custom_pr_generate_reset_link( user->ID ) if ( is_wp_error( link ) ) { // For internal logging you might log the WP_Error return neutral_message } // Build email subject = Password reset link message = Hello,nn message .= Click the link below to reset your password. The link expires in 1 hour.nn message .= link . nn message .= If you did not request this, you can ignore this message. // Send e-mail (use wp_mail headers if desired) wp_mail( user->user_email, subject, message ) return neutral_message } ?gt
Step 3 — Landing page shortcode (verification and form)
Create a shortcode that will be the content of the WordPress page with slug custom-password-reset. The shortcode will do two things:
- When no query parameters: show a form to request a reset by email.
- When query parameters uid, token, expires, sig are present: verify signature and expiry, ensure the token matches the stored hash, and show the enter new password form.
Below is an example shortcode. Note: the HTML form markup is contained within the PHP code and is provided here as an example you can adapt the markup to match your theme.
lt?php // Register shortcode add_shortcode( custom_password_reset, custom_pr_shortcode_handler ) function custom_pr_shortcode_handler( atts ) { // If form submission handling goes via admin-post, the shortcode just prints forms // Check GET params to decide which form to show uid = isset( _GET[uid] ) ? intval( _GET[uid] ) : 0 token = isset( _GET[token] ) ? sanitize_text_field( _GET[token] ) : expires = isset( _GET[expires] ) ? intval( _GET[expires] ) : 0 sig = isset( _GET[sig] ) ? sanitize_text_field( _GET[sig] ) : // If token parameters are present, validate signature expiry stored token hash if ( uid token expires sig ) { // Verify signature payload = uid . . token . . expires expected_sig = hash_hmac( sha256, payload, CUSTOM_PASSWORD_RESET_KEY ) if ( ! hash_equals( expected_sig, sig ) ) { return ltpgtInvalid or tampered link.lt/pgt } if ( time() > expires ) { return ltpgtThis link has expired.lt/pgt } stored_hash = get_user_meta( uid, _custom_pr_token_hash, true ) if ( empty( stored_hash ) ! hash_equals( stored_hash, hash( sha256, token ) ) ) { return ltpgtInvalid or already used link.lt/pgt } // Verified: show new password form. The form posts to admin-post.php?action=custom_pr_do_reset ob_start() ?gt ltform method=post action=gt ltinput type=hidden name=action value=custom_pr_do_resetgt lt?php wp_nonce_field( custom_pr_do_reset, _wpnonce ) ?gt ltinput type=hidden name=uid value=gt ltinput type=hidden name=token value=gt ltinput type=hidden name=expires value=gt ltinput type=hidden name=sig value=gt ltpgtltlabelgtNew password: ltinput type=password name=new_password requiredgtlt/labelgtlt/pgt ltpgtltlabelgtConfirm password: ltinput type=password name=new_password_confirm requiredgtlt/labelgtlt/pgt ltpgtltbutton type=submitgtSet new passwordlt/buttongtlt/pgt lt/formgt lt?php return ob_get_clean() } // Otherwise show the email request form ob_start() ?gt ltform method=post action=gt ltinput type=hidden name=action value=custom_pr_request_resetgt lt?php wp_nonce_field( custom_pr_request_reset, _wpnonce ) ?gt ltpgtltlabelgtEmail address: ltinput type=email name=email requiredgtlt/labelgtlt/pgt ltpgtltbutton type=submitgtRequest password resetlt/buttongtlt/pgt lt/formgt lt?php return ob_get_clean() } ?gt
Step 4 — POST handlers (request reset actions)
Hook both admin_post_nopriv and admin_post so requests work whether the user is logged in or not.
lt?php // Hook handlers for both logged-in and not-logged-in users add_action( admin_post_nopriv_custom_pr_request_reset, custom_pr_handle_request_reset ) add_action( admin_post_custom_pr_request_reset, custom_pr_handle_request_reset ) function custom_pr_handle_request_reset() { // Nonce check if ( ! isset( _POST[_wpnonce] ) ! wp_verify_nonce( _POST[_wpnonce], custom_pr_request_reset ) ) { wp_die( Invalid request ) } email = isset( _POST[email] ) ? sanitize_email( wp_unslash( _POST[email] ) ) : result = custom_pr_request_reset_by_email( email ) // Redirect back to the same page with a neutral message redirect = wp_get_referer() ? wp_get_referer() : home_url() redirect = add_query_arg( pr_requested, 1, redirect ) wp_safe_redirect( redirect ) exit } // Handler for submitting the new password add_action( admin_post_nopriv_custom_pr_do_reset, custom_pr_handle_do_reset ) add_action( admin_post_custom_pr_do_reset, custom_pr_handle_do_reset ) function custom_pr_handle_do_reset() { // Nonce check if ( ! isset( _POST[_wpnonce] ) ! wp_verify_nonce( _POST[_wpnonce], custom_pr_do_reset ) ) { wp_die( Invalid request ) } uid = isset( _POST[uid] ) ? intval( _POST[uid] ) : 0 token = isset( _POST[token] ) ? sanitize_text_field( wp_unslash( _POST[token] ) ) : expires = isset( _POST[expires] ) ? intval( _POST[expires] ) : 0 sig = isset( _POST[sig] ) ? sanitize_text_field( wp_unslash( _POST[sig] ) ) : new_password = isset( _POST[new_password] ) ? _POST[new_password] : new_password_con = isset( _POST[new_password_confirm] ) ? _POST[new_password_confirm] : // Basic checks if ( ! uid empty( token ) empty( sig ) ) { wp_die( Invalid parameters ) } if ( new_password !== new_password_con ) { wp_die( Passwords do not match ) } // Validate signature payload = uid . . token . . expires expected_sig = hash_hmac( sha256, payload, CUSTOM_PASSWORD_RESET_KEY ) if ( ! hash_equals( expected_sig, sig ) ) { wp_die( Invalid or tampered link. ) } if ( time() > expires ) { wp_die( This link has expired. ) } // Validate stored token hash stored_hash = get_user_meta( uid, _custom_pr_token_hash, true ) if ( empty( stored_hash ) ! hash_equals( stored_hash, hash( sha256, token ) ) ) { wp_die( Invalid or already used link. ) } // All checks passed, set the password wp_set_password( new_password, uid ) // Delete token expiry meta to prevent reuse delete_user_meta( uid, _custom_pr_token_hash ) delete_user_meta( uid, _custom_pr_expires ) // Destroy all sessions for this user for security (WP 4.7 ) if ( function_exists( wp_destroy_all_sessions ) ) { wp_destroy_all_sessions( uid ) } // Redirect to login with a success flag login_url = wp_login_url() redirect = add_query_arg( password_reset, success, login_url ) wp_safe_redirect( redirect ) exit } ?gt
Security hardening and best practices
- Use HTTPS: Ensure your site is served over TLS to prevent token interception.
- Store only hashed tokens: We used hash(sha256, token) in usermeta. If database is compromised, attackers cant directly use old tokens.
- Limit lifetime: Short TTLs (e.g., 1 hour) reduce exposure.
- One-time use: Delete stored token after successful reset to prevent reuse.
- Signature key storage: Keep CUSTOM_PASSWORD_RESET_KEY out of version control (set it in wp-config.php on each environment), and make it a long random value.
- Compare signatures safely: Use hash_equals to avoid timing attacks.
- Throttle requests: Implement rate limiting for reset requests per IP and per account (transients or an external rate limiter) to prevent abuse.
- Neutral responses: When requesting a reset by email, always return the same message regardless of whether the account exists to avoid user enumeration.
- Log suspicious events: Consider logging repeated failed signature checks / expired-token usage for investigation.
- CSRF protection: Use nonces in forms (example demonstrates wp_nonce_field).
- Password strength: Enforce your site’s password policy (validate length/complexity) or use wp_set_password and let higher-level policies apply before saving.
Troubleshooting common pitfalls
- Signature mismatch: Ensure CUSTOM_PASSWORD_RESET_KEY is defined and identical between the generator and verifier. If you changed the key, previously issued links will break.
- Invalid or missing token: Make sure the stored usermeta keys are created successfully and not getting stripped by any plugin that sanitizes metadata.
- Time drift: Server clock must be accurate. If using multiple servers, ensure they’re synchronized (NTP).
- Page slug mismatch: The example uses home_url(/custom-password-reset/). Ensure you create a WP page with that slug and the shortcode [custom_password_reset] as its content.
- Emails not delivering: Verify wp_mail configuration or use SMTP plugin/3rd-party mail services for reliability.
Testing checklist
- Create a test user with a known email.
- Submit the request form, confirm an email arrives with a link.
- Click the link, verify you see the new-password form.
- Submit mismatched passwords and verify the proper validation triggers.
- Submit a valid new password, confirm login works with new password and old sessions are invalidated.
- Attempt to reuse the same link — it should be rejected.
- Try tampering with any query parameter (uid, token, expires) and confirm the signature check rejects it.
Advanced notes and variations
- Stateless approach: You can build signed links without storing tokens server-side by embedding all required info in the payload and validating HMAC only. The downside: you cannot make the link single-use without additional server-side state (e.g., storing last-password-reset timestamp to invalidate previous links).
- Shorter URLs: If you prefer not to expose the raw token in the URL, you can base64url-encode components. But avoid using base64 without URL-safe variants and ensure consistent decoding when verifying.
- Use WP sessions or a database table: For high-volume sites, consider storing tokens in a dedicated table with indices and retention cleanup (cron) instead of usermeta.
- Use plugins or libraries: If you prefer JWT or other token libraries, be sure to sign them securely and keep keys safe. The HMAC approach shown here is simple and robust for this use case.
Conclusion
The combination of a cryptographically-random token (stored hashed) and an HMAC signature over the parameters gives you a robust reset-link mechanism that defends against tampering and prevents reuse. Implement the request form, landing shortcode, and POST handlers shown above, place the secret in wp-config.php, and follow the security recommendations (HTTPS, throttling, logging) for a production-ready solution.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |