Bojan Josifoski < wp developer />

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

October 31, 2025 • Bojan

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


1) Passwordless Magic Links

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

Issue a link

$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


4) Tenant-aware links & deep-links

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


6) Email & deliverability for magic links


7) Why this works (and scales)


What I’d add next

Bojan Josifoski

Bojan Josifoski

Engineer behind BBPro — a WordPress-based platform powering 100+ financial institutions. I write about performance, clarity, and building digital infrastructure that lasts.

← Back to Blog