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:
- Passwordless Magic Links (default, zero-friction)
- 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
- Multisite: each customer = one site (
blog_id). - Auth gateway: main site handles all auth flows (passwordless + SSO).
- Tenant mapping: auth requests carry a signed
site_idand post-login redirect. - Session: standard WP auth cookies, network-wide where safe, or per-site when needed.
- Security: HMAC/JWT tokens, short TTL, single-use, strict scopes, signed redirects.
1) Passwordless Magic Links
Goal: click link → get signed in → land on the correct tenant dashboard.
No passwords, no username field. Just email.
Flow
- User enters email + (optionally) tenant subdomain.
- Server validates the email → looks up user + tenant membership.
- 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)
- 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. - 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
- Short TTL (5–10 minutes).
- Single use (delete after consume).
- Strict redirect allow-list (no external hosts).
- Rate limit by IP/email (throttle requests).
- Link copy protection (no query logging; avoid echoing tokens in JS).
- Deliverability: use Postmark/Elastic Email, DMARC/SPF/DKIM set correctly.
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
- Tenant admin clicks Connect SSO in Settings.
- We collect their Issuer, Client ID, and allowed domains or enforce discovery.
- 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. - Callback exchanges
code+code_verifierfor tokens. - We validate the ID token (issuer, audience, nonce, signature, exp).
- 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?
- Works cleanly with public clients and avoids leaking a client secret.
- Standard across Google, Microsoft Entra, Okta, OneLogin, WorkOS, etc.
Role mapping (per tenant)
- Store an IdP → WP role map in the tenant’s options (e.g., “sales@company.com → sales_rep”).
- Optionally enforce domain allow-list (
@customer.com) to block personal emails.
3) Session strategy in Multisite
- Default to per-site auth cookies for strict isolation, or
- Set cookie domain to the parent domain for seamless switching (only if your subdomains are a single customer’s zones or you trust cross-tenant visibility of session presence).
- Always set cookies
Secure,HttpOnly,SameSite=Lax.
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
- CSRF & Replay: use
stateandnonce, compare + delete on use. - Rotate HMAC/JWT secrets: versioned keys; accept
kidwindows during rotation. - Audit log: login successes/failures, token refresh failures, SSO errors per tenant.
- Rate limiting: throttle magic-link requests per IP/email/tenant; back-off.
- Alerting: notify on repeated SSO failures or token exchange errors.
- Feature flags: enable SSO per tenant without redeploys.
6) Email & deliverability for magic links
- Use a trusted sender (Postmark/Elastic Email).
- Sign with SPF, DKIM, DMARC.
- Keep subject lines predictable (“Sign in to SampleHQ”).
- Use short, branded subdomain links (e.g.,
login.samplehq.io/m/...). - Include text fallback (not just HTML).
- Expire links quickly; say so in the email.
7) Why this works (and scales)
- Multisite gives natural isolation.
- A central auth gateway simplifies SSO & passwordless logic.
- Tokens are short-lived and single-use, with strict redirect controls.
- Tenants can start with magic links and upgrade to SSO later—no migration pain.
What I’d add next
- Passkeys (WebAuthn) for high-trust users.
- Organization-wide SSO enforcement (turn off magic links for a tenant).
- Device-bound magic links (tie to UA/IP snapshot as a soft check).
- Signed app-links for future desktop/mobile wrappers.
Bojan