Bojan Bojan
Ask AI
I am the Great and Powerful Oz, keeper of Bojan's secrets and his 298 five-star reviews. Step forward and ask, traveler, or tell me who you are and I shall tailor the spectacle.
I'm hiringI have a projectJust curiousAI & platforms
powered by cloudflare workers ai · llama
← All writing
UncategorizedNov 01, 20256 min read

Passwordless & SSO in a WordPress Multisite SaaS (How I Built It for SampleHQ)

I wanted authentication in SampleHQ to be boring—in a good way. No password resets, no “I can’t log in” tickets, and no copy-pasted secrets spread across tenants. The result is a two-lane system: Passwordless Magic Links (default, zero-friction) SSO via OpenID Connect (OIDC) with PKCE (optional per tenant) Under the hood it’s WordPress Multisite, but

I wanted authentication in SampleHQ to be boring—in a good way.
No password resets, no “I can’t log in” tickets, and no copy-pasted secrets spread across tenants. The result is a two-lane system:

  1. Passwordless Magic Links (default, zero-friction)
  2. SSO via OpenID Connect (OIDC) with PKCE (optional per tenant)

Under the hood it’s WordPress Multisite, but users shouldn’t feel that. They sign in once and land in the right workspace with the right role—done.


Architecture at a glance


Goal: click link → get signed in → land on the correct tenant dashboard.
No passwords, no username field. Just email.

Flow

  1. User enters email + (optionally) tenant subdomain.
  2. Server validates the email → looks up user + tenant membership.
  3. Server generates a single-use, short-lived token (HMAC or JWT) with:
    • sub (user_id)
    • site_id (blog_id)
    • exp (e.g., 10 minutes)
    • nonce (random)
    • redirect (allowed, vetted)
  4. We store a hashed copy of the token (or the nonce) with an expiry (user_meta or a dedicated table) and send the link via transactional email.
  5. User clicks the link → token is verified → we consume it → call wp_signon() → set cookies → redirect to tenant dashboard.

Token format (HMAC, simple + fast)

function sf_magic_token_create(array $claims, int $ttl = 600): string {
    $claims['exp'] = time() + $ttl;
    $payload       = base64_encode(json_encode($claims, JSON_UNESCAPED_SLASHES));
    $sig           = hash_hmac('sha256', $payload, AUTH_SALT); // server secret
    return $payload . '.' . $sig;
}

function sf_magic_token_verify(string $token): ?array {
    [$payload, $sig] = explode('.', $token, 2);
    $calc = hash_hmac('sha256', $payload, AUTH_SALT);
    if (!hash_equals($calc, $sig)) return null;
    $claims = json_decode(base64_decode($payload), true);
    if (!$claims || time() > ($claims['exp'] ?? 0)) return null;
    return $claims; // sub, site_id, nonce, redirect...
}

Single-use storage (hashed nonce)

function sf_store_nonce(int $user_id, string $nonce, int $exp) {
    $hash = hash('sha256', $nonce . SECURE_AUTH_SALT);
    update_user_meta($user_id, 'sf_magic_'.$hash, $exp); // key = hash, value = expiry
}

function sf_consume_nonce(int $user_id, string $nonce): bool {
    $hash   = hash('sha256', $nonce . SECURE_AUTH_SALT);
    $expiry = get_user_meta($user_id, 'sf_magic_'.$hash, true);
    if (!$expiry || time() > (int)$expiry) return false;
    delete_user_meta($user_id, 'sf_magic_'.$hash); // one-time use
    return true;
}
$user  = get_user_by('email', $email);
$nonce = wp_generate_password(24, false);
sf_store_nonce($user->ID, $nonce, time()+600);

$claims = [
  'sub'      => $user->ID,
  'site_id'  => $site_id,
  'nonce'    => $nonce,
  'redirect' => '/dashboard'
];
$token = sf_magic_token_create($claims);
$url   = add_query_arg(['token' => rawurlencode($token)], home_url('/magic-login'));

Verify endpoint (/magic-login)

$claims = sf_magic_token_verify($_GET['token'] ?? '');
if (!$claims) wp_die('Invalid or expired link.');

$user_id = (int)$claims['sub'];
if (!sf_consume_nonce($user_id, $claims['nonce'])) wp_die('Link already used.');

wp_set_current_user($user_id);
wp_set_auth_cookie($user_id, true, is_ssl());

// switch to tenant then redirect
switch_to_blog((int)$claims['site_id']);
$redirect = sf_safe_redirect_path($claims['redirect'] ?? '/');
restore_current_blog();

wp_safe_redirect($redirect);
exit;

Safety belts


2) SSO via OpenID Connect (OIDC) + PKCE

Some tenants want Google/Microsoft/Okta, some want their internal IdP. I standardized on OIDC with PKCE so the flow is consistent, then map claims to WordPress users/roles.

Flow

  1. Tenant admin clicks Connect SSO in Settings.
  2. We collect their Issuer, Client ID, and allowed domains or enforce discovery.
  3. Users hit /sso/{tenant} → we generate state + code_verifier (PKCE), save to a short-lived server store, and redirect to the IdP’s authorize URL.
  4. Callback exchanges code + code_verifier for tokens.
  5. We validate the ID token (issuer, audience, nonce, signature, exp).
  6. Map identity → user (by email or sub) → ensure membership in tenant → log in.

PKCE bits (server-side)

$verifier  = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');

// store $verifier keyed by session/state (server side)

Callback sketch

// 1) exchange code for tokens (with code_verifier)
$resp = wp_remote_post("$issuer/oauth/token", ['body' => [
  'grant_type'    => 'authorization_code',
  'code'          => $_GET['code'],
  'client_id'     => $client_id,
  'redirect_uri'  => $redirect_uri,
  'code_verifier' => $verifier_from_store
]]);

// 2) validate ID token (use jwks to verify signature)
$id_token = json_decode(wp_remote_retrieve_body($resp), true)['id_token'];
$claims   = sf_validate_id_token($id_token, $issuer, $client_id); // iss, aud, exp, nonce

// 3) map to WP user
$email   = strtolower($claims['email'] ?? '');
$user    = get_user_by('email', $email) ?: sf_provision_user($email, $claims);
$site_id = sf_tenant_from_state($_GET['state']); // we encoded tenant in state

// 4) ensure membership + role
sf_ensure_membership($user->ID, $site_id, sf_role_from_claims($claims));

// 5) sign in + redirect
wp_set_current_user($user->ID);
wp_set_auth_cookie($user->ID, true, is_ssl());
switch_to_blog($site_id);
wp_safe_redirect('/dashboard');
exit;

Why PKCE even on server?

Role mapping (per tenant)


3) Session strategy in Multisite


All auth links carry a signed site_id and a relative redirect (never absolute to a foreign host). Build helpers:

function sf_tenant_link(int $site_id, string $path, array $args = []): string {
    $base = get_site_url($site_id, $path);
    return add_query_arg($args, $base);
}

Use it everywhere (emails, dashboards) so users always land inside the correct tenant.


5) Hardening & Ops



7) Why this works (and scales)


What I’d add next

Related writing
Nov 09, 2025

When Analytics Start Writing Comedy Scripts

Nov 01, 2025

Designing the Ideal Sample Ordering Experience

Jun 13, 2026

From Zapier to AI Agents: The Four Levels of Business Automation