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.
See Webhooks for the event catalog and signature verification.
Age verification without sharing the birthday
The wa:age.tiers scope returns boolean checks
(age_gate.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.
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.