Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
post
page
sponsor
Filter by Categories
Alternative Giving
Announcements
Business
Business Coaching
Charities We've Helped
Charity
Coding
Engineering
Guest Blog
How To
Marketing
Misc
PIFster Blog
Product Strategy
Search Engine Optimization (SEO)

Passwordless Authentication To Banish “Forgot Password” Headaches

An open envelope with a glowing key inside, surrounded by floating padlock icons and sparkles, symbolizing secure email communication with Magic Links or passwordless authentication.
TL;DR: We replaced traditional username/password login on PIFster with magic links — single-use, time-limited URLs emailed to the user. No passwords to forget, no reset flows to build, and our donors authenticate in one click. This post explains why we went passwordless, how the passwordless authentication works, and how to build it on WordPress.

Why We Chose Passwordless Authentication

PIFster is a charitable giving platform. Our users aren't logging in every day — they visit once a month to vote for their favorite charity, maybe donate, and leave. Asking them to remember a password for a site they use twelve times a year is a friction machine.

We watched the support tickets:

  • “I forgot my password” — over and over
  • Users abandoning the donation flow when the login wall appeared
  • Password reset emails that looked identical to phishing

Magic links solve all three. The user enters their email, gets a link, clicks it, and they're in. No password field. No “forgot password” page. No friction.

How Magic Links Work (The 30-Second Version)

1. User enters email → “Send me a sign-in link”
2. Server creates a single-use token, stores it with a TTL
3. Server emails a URL containing that token
4. User clicks the link
5. Server validates the token (exists? expired? already used?)
6. If valid: log the user in and redirect. If not: show an error.

That's it. The token is the credential. Once used, it's dead. Once expired, it's dead. No password ever touches the wire.

The Architecture

Our implementation has four pieces:

  1. REST endpoint — receives the email, creates the token, sends the email
  2. Token storage — database table with TTL and single-use enforcement
  3. Email service — sends the magic link with context-aware content
  4. Consume handler — validates the token and logs the user in

Piece 1: The REST Endpoint

When a user clicks “Send me a sign-in link,” the frontend POSTs to a REST route. The server validates the email, checks rate limits, creates a token, and fires off the email.

<?php declare(strict_types: 1); // POST /wp-json/yourplugin/v1/auth/intent function handle_magic_link_request(WP_REST_Request $request): WP_REST_Response|WP_Error { $email = strtolower(trim((string) $request->get_param('email'))); if ($email === '' || !is_email($email)) { return new WP_Error('invalid_email', 'A valid email is required.', ['status' => 400]); } // Rate limit: 3 requests per email per 15 minutes if (!check_rate_limit($email)) { return new WP_Error('rate_limited', 'Too many attempts.', ['status' => 429]); } // Create a single-use token with 15-minute TTL $token = create_auth_token($email, ttl: 900); // Build the magic link URL $url = admin_url('admin-post.php') . '?' . http_build_query([ 'action' => 'consume_magic_link', 'token' => $token, ]); send_magic_link_email($email, $url); return new WP_REST_Response(['ok' => true], 201); }
Important: Always return a success response even if the email doesn't match a user. If you return an error for unknown emails, attackers can enumerate your user list.

Piece 2: Token Storage

Tokens need three properties: single-use, time-limited, and atomically consumed. That last one matters — if two requests hit the same token simultaneously, only one should succeed.

We use a dedicated database table:

CREATE TABLE wp_auth_intents ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, token VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL, user_id BIGINT UNSIGNED NULL, created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, used_at DATETIME NULL, meta TEXT NULL, INDEX idx_token (token), INDEX idx_expires (expires_at) );

The meta column is a JSON blob for anything you need downstream — redirect URLs, action context, feature flags. We store the user's intended action (vote, redeem gift card, etc.) here so the consume handler knows what to do after login.

Token generation is straightforward — just random bytes:

$token = bin2hex(random_bytes(32)); $wpdb->insert('wp_auth_intents', [ 'token' => $token, 'email' => $email, 'created_at' => gmdate('Y-m-d H:i:s'), 'expires_at' => gmdate('Y-m-d H:i:s', time() + $ttl), ]);

Piece 3: The Consume Handler

When the user clicks the link, WordPress routes it to your handler. The handler validates the token, marks it used (atomically), logs the user in, and redirects.

function consume_magic_link(): void { $token = $_GET['token'] ?? ''; if ($token === '') { wp_safe_redirect(home_url('/login/?error=invalid')); exit; } // Atomic: SELECT ... FOR UPDATE, check not used/expired, UPDATE $intent = consume_token($token); if ($intent === null) { wp_safe_redirect(home_url('/login/?error=expired')); exit; } // Look up the WordPress user by email $user = get_user_by('email', $intent['email']); if ($user) { wp_set_auth_cookie($user->ID, false, is_ssl()); } // Redirect to wherever the user was going $redirect = $intent['meta']['redirect_to'] ?? home_url('/profile/'); wp_safe_redirect($redirect); exit; } // Register for both logged-in and logged-out users add_action('admin_post_nopriv_consume_magic_link', 'consume_magic_link'); add_action('admin_post_consume_magic_link', 'consume_magic_link');

The atomic consumption is the key security piece. SELECT ... FOR UPDATE locks the row until the transaction commits. If two requests race, one gets the lock and consumes the token; the other sees used_at is already set and bails. No double-logins, no replay attacks.

function consume_token(string $token): ?array { global $wpdb; $table = $wpdb->prefix . 'auth_intents'; $wpdb->query('START TRANSACTION'); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE token = %s FOR UPDATE", $token ) ); if (!$row || $row->used_at !== null || $row->expires_at < gmdate('Y-m-d H:i:s')) { $wpdb->query('ROLLBACK'); return null; } $wpdb->update($table, ['used_at' => gmdate('Y-m-d H:i:s')], ['token' => $token]); $wpdb->query('COMMIT'); return [ 'email' => $row->email, 'meta' => json_decode($row->meta ?? '{}', true), ]; }

Security Layers We Added

Magic links shift your attack surface from “guess the password” to “intercept the email.” That's actually a better trade in most cases, but you still need defense in depth.

  • 15-minute TTL — tokens expire fast. No “I'll click this tomorrow.”
  • Single-use enforcement — atomic DB transaction prevents replay.
  • Rate limiting — 3 requests per email per 15 minutes, 12 per day. Per-IP limits too.
  • Cloudflare Turnstile — bot protection on the request endpoint. Invisible to real users, blocks automated abuse.
  • Origin validation — the REST endpoint checks the HTTP Origin/Referer header against an allowlist.
  • Redirect sanitization — the redirect_to URL in the token metadata is validated against your site's domain. No open redirects.

The Trick That Made It Really Useful: Purpose Routing

Here's where our implementation goes beyond basic magic links. Every token carries a purpose — an enum that tells the consume handler why the user is authenticating:

enum AuthIntentPurpose: string { case LOGIN = 'login'; case GIFT_CARD_REDEEM = 'gift_card_redeem'; case REGISTRATION_VERIFY = 'registration_verify'; case POST_DONATION = 'post_donation'; case VOTE_INTENT = 'vote_intent'; }

Each purpose has its own handler that decides what happens after authentication:

  • Vote intent — log in and redirect to the voting page with the charity pre-selected
  • Gift card redeem — log in and immediately start the redemption flow
  • Post-donation — log in and show a “thank you” page with donation details
  • Registration verify — confirm the email and finish account creation

The consume handler uses a strategy pattern — a registry of purpose handlers:

$handler = $purposeRegistry->get($purpose); $outcome = $handler->handle($context);

This means adding a new “reason to authenticate” is one small class. No if/else chains growing forever.

Context-Aware Emails

The purpose also drives the email content. A magic link for voting gets a different subject line and body than one for gift card redemption:

  • Vote: “PIFster sign-in link to vote for Habitat for Humanity”
  • Gift card: “PIFster - Redeem your gift card”
  • Post-donation: “PIFster Thanks You - Access Your Account”
  • New user: “PIFster - Welcome! Access Your Account”

Same infrastructure, different message. The user sees a relevant email instead of a generic “click here to sign in.” This matters for open rates and trust.

What About Users Who Want a Password?

We still support traditional passwords as an option. After authenticating via magic link, users can set a password in their profile if they prefer. But most don't. Our data shows fewer than 10% of users bother setting one — and the ones who do still use magic links half the time.

The key insight: make magic links the default, not an alternative. If you offer both equally, users will choose the password (because it's familiar), hate it (because they forget it), and then ask for a reset link — which is just a worse magic link.

Tips If You Build This

  1. Start with transients, graduate to a table. WordPress transients (set_transient) work for prototyping. But for production, you want a real table with proper indexes and FOR UPDATE locking.
  2. Always return 200/201 for unknown emails. Leaking “this email isn't registered” is an information disclosure vulnerability. Send the same response regardless.
  3. Keep TTLs short. 15 minutes is generous. Some implementations use 5 minutes. The shorter the window, the smaller the attack surface.
  4. Purge expired tokens. Run a scheduled cleanup (WordPress cron or a simple DELETE WHERE expires_at < NOW()) to keep the table from growing forever.
  5. Log token consumption, not token values. Log the token hash, the email hash, the purpose, and the outcome. Never log the raw token — it's a credential.
  6. Add Turnstile or reCAPTCHA. The magic link request endpoint is unauthenticated by definition. Without bot protection, someone can spam emails to arbitrary addresses using your mail server. Cloudflare Turnstile is free and invisible to real users.

PIFster is a community-driven charitable giving platform where donors vote on which charity receives the community's pooled donations each month. We run on WordPress with pay-what-you-want checkout, gift memberships for donor acquisition, and a gamified referral engine. If you're building something similar, we'd love to hear about it.

About The Author

PIFster Admin

Founder and administrator of PIFster, the Pay It Forward charity. We thank you for being here.
Leave a Reply

Your email address will not be published. Required fields are marked *

    Illustration of three people sitting at a table with colorful speech bubbles above them on a light blue background. Large text in the center reads “Join Newsletter.” Perfect for charity, giving, or help-themed campaigns.
    Footer Form

    © 2026 PIFster. All rights reserved. | PIFster is Veteran Owned & Operated | EIN: 92-1821142