Passwordless & SSO in a WordPress Multisite SaaS (How I Built It for SampleHQ)
October 31, 2025 • Bojan
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:
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.
blog_id).site_id and post-login redirect.Goal: click link → get signed in → land on the correct tenant dashboard.
No passwords, no username field. Just email.
sub (user_id)site_id (blog_id)exp (e.g., 10 minutes)nonce (random)redirect (allowed, vetted)nonce) with an expiry (user_meta or a dedicated table) and send the link via transactional email.wp_signon() → set cookies → redirect to tenant dashboard.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...
}
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'));
/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;
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.
/sso/{tenant} → we generate state + code_verifier (PKCE), save to a short-lived server store, and redirect to the IdP’s authorize URL.code + code_verifier for tokens.$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)
// 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;
@customer.com) to block personal emails.Secure, HttpOnly, SameSite=Lax.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.
state and nonce, compare + delete on use.kid windows during rotation.login.samplehq.io/m/...).
Engineer behind BBPro — a WordPress-based platform powering 100+ financial institutions. I write about performance, clarity, and building digital infrastructure that lasts.