Privasys
Privasys ID

Identity Provider (IdP)

The Privasys ID identity provider — OIDC issuer, FIDO2 ceremony coordinator, attestation-claims signer.

The Privasys IdP runs at https://privasys.id. It is a standard OpenID Connect issuer with a JWKS endpoint, a discovery document, an authorization endpoint, and a token endpoint. The unusual parts are how the FIDO2 ceremonies are coordinated and what claims the issued tokens carry.

OIDC surface

EndpointPurpose
https://privasys.id/.well-known/openid-configurationDiscovery document
https://privasys.id/jwksJWKS, ES256 signing keys
https://privasys.id/oidc/authorizeAuthorization endpoint
https://privasys.id/oidc/tokenToken endpoint (authorization-code, refresh-token, JWT-bearer for service accounts)

Audience: a relying-party app's tokens are addressed to its own client id. The platform audience, privasys-platform, is reserved for the platform's own clients (the developer portal and the CLI); a third-party client cannot obtain it.

Subjects: one per application

Every client sees a person under its own subject, which no other client sees:

sub = base64url( HMAC-SHA256( K, "privasys-subject/v1" ‖ 0 ‖ client_id ‖ 0 ‖ account ) )

K is derived from the IdP's own secret, so a subject cannot be computed, or linked to another, by anyone else. Two applications therefore cannot match their records to find a person, and discovery advertises "subject_types_supported": ["public", "pairwise"].

  • Stable for your client. The same person always arrives under the same sub, across sign-ins, refreshes and API keys minted for your client.
  • Cross-application access is a grant, never a shared identifier. When one application needs something another holds for the user (an assistant reading their Drive, for example), it asks, and the user approves a capability on their wallet. See Apps and the wallet.
  • The platform still knows the account. Billing, ownership and notifications are keyed by the account. Tokens for the platform audience carry it as privasys_account, and the control plane resolves any application's subject to its account through the IdP, server side.

Every client registered from October 2026 gets its own subject from the start. Clients registered before, the platform's own included, keep the account as their subject until each moves its data to its own: moving changes every sub the client holds, so it is done with the client's owner.

Tokens are signed with ES256 (P-256). Keys rotate on a fixed schedule with overlap; clients should always honour the JWKS rather than caching keys by ID.

FIDO2 / WebAuthn ceremonies

Standard WebAuthn flow with one twist: the IdP coordinates the ceremony but the assertion is performed by the wallet on behalf of the browser, mediated by the broker (a small WebSocket relay) and by a QR-code or push-notification rendezvous.

relying-party app → SDK iframe → IdP /fido2/begin → broker → wallet
                                                                ↓
                                                          biometric + RA-TLS
                                                                ↓
                                IdP /fido2/complete ←───── assertion

Two endpoints:

  • POST /fido2/{register,authenticate}/begin — returns the WebAuthn challenge and pairing material. Accepts an optional binding_challenge query parameter (used by session-relay; see below).
  • POST /fido2/{register,authenticate}/complete — receives the assertion, validates it, mints the OIDC code or session.

Standard claims

Every token carries the standard OIDC set: sub, aud, iss, iat, exp, nonce, nbf. Plus:

  • roles — array of strings, on tokens for the platform audience; the IdP is the source of truth for role assignments. Apps validate offline against the JWKS; no role-lookup round trip.
  • privasys_account — on platform-audience tokens only: the account the platform keys everything by, whatever subject the client sees.
  • email, name — present only on freshly issued tokens from a flow that captured them (wallet-supplied or social-IdP-federated). Refresh tokens do not carry them — they were transient. Apps that need the user's email after refresh should persist it themselves on first auth.

Session-relay claims

For flows initiated with mode: "session-relay" in the QR payload, the IdP additionally embeds:

ClaimMeaning
att_verified: trueThe wallet ran an RA-TLS handshake and presented the result.
att_quote_hashSHA-256 of the verified enclave RA-TLS leaf certificate.
att_oidsThe attestation OIDs the wallet snapshotted (Merkle root, runtime-version-hash, workload-code-hash, etc.). Pass-through; the IdP does not re-verify.
session.idThe opaque session identifier the enclave allocated.
session.enc_pubThe enclave's ephemeral P-256 public key (SEC1, base64url).
session.sdk_pub_bindSHA-256 of the SDK's ephemeral public key — prevents JWT replay against a different SDK instance.
session.expires_atUnix timestamp at which the enclave's in-memory session entry will be evicted.

These claims are what makes the JWT a signed proof of attestation verification, not just "the wallet talked to the IdP". The signing key being compromised does not let an attacker derive the session key (forward secrecy from the SDK's ephemeral keypair), but it would let them forge attestation-verified claims — so the JWKS rotation policy is conservative and the SDK pins att_quote_hash against its own configured policy.

The binding-challenge check

The single load-bearing security check in session-relay mode happens on /fido2/authenticate/complete. The IdP recomputes:

expected = SHA-256(
    "privasys-session-relay/v1" ||
    nonce || sdk_pub || quote_hash || enc_pub || session_id)

…from the binding_challenge it stored at /begin and from the query-string inputs at /complete. It then subtle.ConstantTimeCompares expected against the value the wallet stored and against clientDataJSON.challenge. Any mismatch returns 400 with no side effect — no row written, no JWT minted.

Without this check, the JWT would not constitute proof of attestation verification — see Technology > Sealed Session for the full trust argument.

Several phones, and recovery

A person's wallet can run on up to five phones. Each phone registers its own passkey on each of the person's identities, marked with a device tag: an HMAC, under a secret only that person's phones hold, of the identity's user id. Tags of one phone on two identities are unrelated values, so the IdP can tell which phone holds a passkey without learning which identities belong together. Push targets are kept per identity and per phone, and a notification goes to every phone.

EndpointPurpose
POST /devices/pair, PUT/GET /devices/pair/{slot}A one-time slot (ten minutes, in memory, read once) that carries the wallet from an existing phone to a new one, sealed to the new phone's key.
POST /devices/enrol-ticket, POST /devices/enrolAn existing phone lets a new one register a passkey on the main account, keeping the other phones' passkeys.
POST /recovery/identity/begin, /enrol, /completeEach identity proves itself with its own recovery key: enrol adds a phone, complete (a lost phone) removes every other passkey.
POST /recovery/identity/revoke-deviceRemoves one phone from one identity and, in the same request, replaces the identity's recovery key.
POST /devices/revokeRemoves one phone from the main account.
POST/GET /devices/relaySealed updates between a person's phones: memory only, fifteen minutes, read once.

An identity's recovery key derives from the wallet's seed and a recovery epoch secret. Removing a phone moves every identity to a new epoch the removed phone never receives, so the copy of the seed it still holds opens nothing. A phrase recovery restores the seed and the data root from a backup sealed under the phrase, reads the epochs and the list of identities and phones from an encrypted registry kept on the main account, then removes every other phone from every identity at once. For recovery the IdP stores only a fingerprint of the phrase, ciphertext it cannot open (the sealed backup and the registry, each versioned so an older phone cannot overwrite a newer write), and one public key per identity.

Service-account authentication

For server-to-server calls (e.g. the management service requesting from the IdP), the IdP supports the OAuth 2.0 JWT-bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer). Service accounts hold an RS256 / ES256 keypair, sign a short-lived assertion with their client_id as iss and sub, and exchange it for a normal access token. No static API keys.

Refresh tokens

Standard OIDC refresh_token grant. The IdP issues opaque refresh tokens with the same TTL discipline as the original session and rotates them on each use. Refresh does not re-include transient profile attributes — they live only on the device, in the wallet.

BYO IdP

The Privasys platform allows individual apps to point at a different OIDC issuer (set on the app's oidc_issuer_url). Tokens are then validated against that issuer's JWKS, and the wallet flow falls back to a federated sign-in for that app. The session-relay extensions are Privasys-IdP-specific — apps using a BYO IdP cannot opt into session-relay mode without the BYO IdP also implementing the binding-challenge check.

Self-hosting

The IdP image is published from github.com/Privasys/identity-platform under AGPL-3.0. Self-hosted deployments need:

  • A PostgreSQL instance (the users table for credential ↔ user-handle mapping).
  • An ES256 signing keypair (generated at first boot, persisted in sealed storage if running inside enclave-os-virtual).
  • A reachable broker for FIDO2 rendezvous, or a custom rendezvous transport.
  • Public DNS + HTTPS (for the JWKS to be reachable by relying parties).

The reference deployment used by privasys.id runs on OVH against a hosted PostgreSQL and the public Privasys broker.

Edit on GitHub