How it works
The privacy model in one read. Read this before you start integrating — the design decisions cascade through every endpoint.
Pairwise subject identifiers
The OIDC sub claim is supposed to be a stable identifier for
a user. By default, the spec lets you use one sub across
every relying party — which means two apps that both connect a given
user can compare sub values and discover they have a
customer in common. That's a privacy leak the moment apps share data.
Whisp3r Auth uses pairwise sub identifiers (the OIDC
spec calls this subject_types_supported: ["pairwise"]).
The sub you see is:
sub = base64url(HMAC-SHA256(user_id || client_id || server_secret)) Stable across sessions for your app. Different in a sibling app. Two
relying parties cannot trivially correlate users. You should still use
the sub as your users-table primary key — just understand
it's local to your client_id.
Consent screen UX
When a user signs in to your app for the first time, they see a consent screen listing every scope you asked for, separated into:
- Required — must approve to sign in. If the user
declines, they're redirected back to your
redirect_uriwitherror=access_denied. - Optional — the user toggles each one individually. Declined optional scopes are simply absent from the userinfo response — no error.
On every subsequent sign-in we skip the consent screen entirely, unless you've added new required scopes since their last consent (in which case we re-prompt for just the additions).
Email relay (the private-email model)
You never receive the user's email address. By default OIDC offers an email scope — Whisp3r Auth doesn't honor it for the value
the spec implies. Instead, when you need to email a user, you POST to /api/relay/email with the
user's sub + your subject + body. We deliver to their
actual address; you only learn whether the message was accepted.
When the user disconnects your app, the relay refuses further messages
from you to that sub. There's no out-of-band channel to
preserve — they're really unsubscribed.
Universal profile sync
The user maintains their canonical profile (name, photo, language, cookie preferences, pronouns, etc.) at /dashboard/profile. Every connected app sees the current values on every userinfo call. When a value changes, we fire a signed webhook to every authorized app that holds the scope covering that field.
Apps can do better than "snapshot at sign-in + webhook nudges."
Whenever a directory-backed field scope is granted
(wa:photo for the picture; any of wa:name.*, wa:username, wa:pronouns, wa:language for the
profile), userinfo also issues an alias — profile@<handle>, avatar@<handle> — alongside the one-shot value.
Apps resolve through the identity directory,
subscribe for push invalidations, and never store name or picture
in their own database. The canonical source stays here, in auth,
on every render. See Identity directory for the protocol.
Either model works. The URL claims are kept for compatibility; the alias claims are recommended for new integrations.
See Webhooks for the full event catalog (user events AND directory events) and the signing envelope.
Age verification without sharing the birthday
The wa:age.tiers scope returns boolean tier checks
(age_at_least_13, 16, 18, 21) computed against the user's stored birthday — but
the birthday itself never crosses the wire. The wa:birthday scope (full date) exists for the rare app
that genuinely needs it (insurance, regulated ID verification) and
gets a much more cautious consent treatment.
Both scopes also carry a verification companion — age_verification and birthdate_verification — shaped { verified, method, verified_at }. Today
these always return { verified: false, method: null, verified_at: null } because the underlying age is self-reported. When ID-document
verification ships, the verified flag flips to true on the next userinfo read. Apps with legal /
compliance requirements should gate on these instead of treating
the bare tier booleans as attested.
Per-app 2FA requirement
On the developer dashboard, each app has a "Require 2FA" toggle.
When on, the consent screen refuses to authorize any user who hasn't
enrolled 2FA on their Whisp3r Auth account, with a CTA pointing them
at /dashboard/security to enable
it. Your id_token additionally carries amr: ["mfa", "otp"] for any user whose session went
through 2FA, regardless of whether your app required it — useful
for runtime decisions in apps that don't need to block at sign-in.
Token lifetimes
- access_token
- 15 minutes. Short on purpose — when a user revokes consent or updates a scope, you see the change within one access-token lifetime even if webhook delivery is delayed.
- refresh_token
- 7 days, rolling. Using a refresh token resets the window.
- id_token
- 15 minutes. Same lifetime as the access token.