Authentication
Overview
The Open Privacy Suite authenticates users through Privado ID zero-knowledge proofs with optional ProofOfHumanity (PoH) verification via Billions. Users prove their identity without revealing private keys, and can optionally link Ethereum addresses via EIP-191 signatures.
For SSO-based authentication using Microsoft Entra ID, see the Azure AD / SSO guide.
Architecture
Auth Flow
Client requests auth challenge
POST /auth/request -- the client initiates authentication by requesting a Privado ID
authorization challenge. The server generates a session ID, creates an authorization request
with a ProofOfHumanity ZK query (if enabled), and returns both to the client.
When REQUIRE_PROOF_OF_HUMANITY=true the request includes a ZK credential query. The circuit,
schema URL, credential type, and the predicate applied to credentialSubject are all configured
(see Configuration). An example assembled from defaults plus a credential query
file containing {"credentialSubject":{"isHuman":{"$eq":1}}}:
{
"id": 1,
"circuitId": "credentialAtomicQueryMTPV2",
"query": {
"allowedIssuers": ["did:polygonid:polygon:amoy:..."],
"credentialSubject": { "isHuman": { "$eq": 1 } },
"context": "https://raw.githubusercontent.com/0xPolygonID/tutorial-examples/main/credential-schema/schemas-examples/proof-of-humanity/proof-of-humanity.jsonld",
"type": "ProofOfHumanity"
}
}
allowedIssuers is set to [BILLIONS_ISSUER_DID]. context and type come from
BILLIONS_CREDENTIAL_SCHEMA_URL and BILLIONS_CREDENTIAL_TYPE. credentialSubject is taken
verbatim from the JSON file pointed to by BILLIONS_CREDENTIAL_QUERY_FILE.
Wallet generates ZK proof
The client's Privado wallet creates a JWZ (JSON Web Zero-knowledge) token that:
- Proves DID ownership without revealing private keys
- Proves possession of a ProofOfHumanity credential (
isHuman=1) - Is cryptographically bound to the authorization request
- Cannot be replayed to other verifiers
Client submits proof
The proof is submitted to one of two endpoints:
/auth/callback?session={session_id}Wallet callback -- the standard flow
/auth/verifyManual submission (development only)
The server retrieves the session, extracts and verifies the JWZ token against the original auth request, checks the PoH credential (if required), and extracts the user DID.
JWT tokens issued (or rejection)
On success the server returns an access/refresh token pair:
{
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 300
}
If the user lacks a valid ProofOfHumanity credential the server responds with 403 Forbidden:
{
"error": "humanity_verification_required",
"message": "Please complete ProofOfHumanity verification at Billions",
"verify_url": "https://app.billions.network"
}
ETH Address Linking
Users can link Ethereum addresses to their DID. There are two link types:
- User-initiated link (
link_type: user) — the user proves ownership by signing an EIP-191 challenge. This is the explicit, user-controlled flow. - System-inferred link (
link_type: system) — when a user submits a signed transaction (eth_sendRawTransaction) and the proxy forwards it successfully, the sender address (cryptographically recovered from the transaction signature) is automatically linked to the user's DID. This enables response filters to recognise transactions without requiring a separate linking step.
User-initiated links take precedence over system-inferred links when both exist for the same address.
User-Initiated Linking
Request challenge
/eth/link/challengeRequest a signing challenge (JWT required)
Returns a nonce and a human-readable message for the user to sign.
{
"nonce": "a1b2c3d4e5f6...",
"message": "Link Ethereum address to DID\n\nI authorize linking this Ethereum address to my decentralized identity.\n\nDID: did:privado:...\nNonce: a1b2c3d4e5f6..."
}
Sign the message
The user signs the message with their Ethereum wallet (MetaMask, etc.) using personal_sign.
Submit signature
/eth/link/verifySubmit signed challenge (JWT required)
{
"nonce": "a1b2c3d4e5f6...",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f...",
"signature": "0x..."
}
View or unlink addresses
/eth/addressesList linked addresses (JWT required)
/eth/addresses/:addressUnlink an address (JWT required)
Address Collision Detection
A single ETH address can be linked by more than one DID — for example, a shared deployer wallet used by a team, or a key-compromise event. When this occurs, the system:
- Allows the link — shared keys are a legitimate pattern (multi-sig, shared deployer, team wallet).
- Emits a SIEM event with elevated severity (
eth_address_linked_collision) so administrators can review. - Surfaces collisions in the admin dashboard — administrators can view all addresses linked to multiple DIDs at
GET /api/v1/admin/eth-addresses/collisions.
If a collision is judged to be a key-compromise event rather than intentional sharing, the administrator can revoke the unwanted link.
Configuration
| Variable | Default | Description |
|---|---|---|
REQUIRE_PROOF_OF_HUMANITY | false | Opt-in flag. When true, login requires a valid credential (Path B). When false, login only proves DID ownership (Path A). Enabling this without populating the other Path B vars causes the server to refuse to start. |
BILLIONS_ISSUER_DID | (none) | Issuer DID whose credentials are accepted (required when PoH enabled) |
PRIVADO_STATE_CONTRACT | 0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896 | On-chain identity state contract address (Privado mainnet default) |
PRIVADO_CIRCUIT_ID | credentialAtomicQueryMTPV2 | iden3 circuit used by the credential query (must match what the issuer signs with) |
BILLIONS_CREDENTIAL_SCHEMA_URL | (PolygonID tutorial schema) | JSON-LD schema URL defining the credential's shape |
BILLIONS_CREDENTIAL_TYPE | ProofOfHumanity | Credential type name declared by the schema |
BILLIONS_CREDENTIAL_QUERY_FILE | (none) | Path to a JSON file containing the credentialSubject predicate (e.g. {"credentialSubject":{"isHuman":{"$eq":1}}}). Required when PoH enabled. |
PRIVADO_RPC_URL | https://rpc-mainnet.privado.id | Privado network RPC endpoint (backs the privado:main state resolver) |
BILLIONS_RPC_URL | https://billions-rpc.eu-north-2.gateway.fm | RPC endpoint for the Billions identity chain (chainID 45056). Backs the billions:main state resolver so DIDs created in the Billions app can authenticate. |
BILLIONS_STATE_CONTRACT | 0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896 | On-chain identity state contract for the Billions chain. Defaults to the shared iden3 cross-chain address; override only if Billions moves it. |
IPFS_GATEWAY | https://ipfs-proxy-cache.privado.id | IPFS gateway for credential schemas |
JWT_SECRET | (auto-generated in dev) | Access token signing secret |
JWT_REFRESH_SECRET | (auto-generated in dev) | Refresh token signing secret |
VERIFIER_ID | (required in prod) | DID of the verifier service |
BASE_URL | http://localhost:8080 | Base URL used for callback URLs |
ENVIRONMENT | development | Set to production to disable /auth/verify endpoint |
Wallet networks (Privado ID & Billions)
Every wallet's DID is anchored on a specific iden3 network — Privado ID wallets on
privado:main, Billions app wallets on billions:main (chainID 45056). During proof
verification the proxy resolves each DID's on-chain identity state through a per-network
resolver. Both networks are registered out of the box, so users can sign in with either
the Privado ID app or the Billions app against a stock deployment.
If a wallet's network has no registered resolver, its proof is rejected before any credential
check. The Billions resolver is configured by BILLIONS_RPC_URL and BILLIONS_STATE_CONTRACT;
the Privado resolver by PRIVADO_RPC_URL and PRIVADO_STATE_CONTRACT. This applies to basic
DID-ownership login (Path A) — it is independent of ProofOfHumanity (Path B).
Credential query file
When Path B is enabled, BILLIONS_CREDENTIAL_QUERY_FILE must point to a JSON file containing a
credentialSubject object with the predicate to enforce. The iden3 query language supports
operators like $eq, $ne, $lt, $gt, $in, $nin, and multi-field predicates.
{
"credentialSubject": {
"isHuman": { "$eq": 1 }
}
}
A multi-field example (if a credential exposes both fields):
{
"credentialSubject": {
"isHuman": { "$eq": 1 },
"jurisdiction":{ "$in": ["CH", "DE"] }
}
}
The proxy assembles the full query at boot by injecting allowedIssuers, context, and type
from the other env vars. The file must only describe the credentialSubject predicate.
Production checklist
Before deploying to production, ensure you have:
- Decided whether to enable Path B (credential check). If yes, set
REQUIRE_PROOF_OF_HUMANITY=trueand populate all of:BILLIONS_ISSUER_DIDPRIVADO_STATE_CONTRACT(or keep default)PRIVADO_CIRCUIT_ID(must match what the issuer signs with — confirm with the issuer)BILLIONS_CREDENTIAL_SCHEMA_URL+BILLIONS_CREDENTIAL_TYPEBILLIONS_CREDENTIAL_QUERY_FILEpointing to a valid JSON file The process refuses to start ifREQUIRE_PROOF_OF_HUMANITY=trueand any of these are missing or invalid.
- If Path B is not enabled, login will only prove DID ownership (no credential check). Users do not need a PoH credential to authenticate.
- Configured strong, unique
JWT_SECRETandJWT_REFRESH_SECRET - Configured
VERIFIER_IDwith your service's DID - Set
BASE_URLto your public URL - Set
ENVIRONMENT=production(disables the/auth/verifyendpoint)
Testing Options
Option A: Mock Implementation (Development)
Use the MockPrivadoVerifier for local development:
ENVIRONMENT=development
REQUIRE_PROOF_OF_HUMANITY=false
- Skips real ZK proof verification
- Returns configurable responses (
isHumantrue/false) - Works without any external infrastructure
- Supports mock tokens:
mock.{did}ormock.jwz.token.{did}
Option B: Run Your Own Issuer Node (E2E Testing)
Run a local Privado ID Issuer Node on Polygon Amoy testnet. Clone the repo, configure for Amoy, and create ProofOfHumanity credentials for test users.
Amoy testnet details:
| Parameter | Value |
|---|---|
| State contract | 0x1a4cC30f2aA0377b0c3bc9848766D90cb4404124 |
| Chain ID | 80002 |
| Faucet | Alchemy Polygon Amoy faucet |
Then configure the proxy (Amoy uses a different state contract than Privado mainnet):
REQUIRE_PROOF_OF_HUMANITY=true
BILLIONS_ISSUER_DID=did:polygonid:polygon:amoy:...
PRIVADO_STATE_CONTRACT=0x1a4cC30f2aA0377b0c3bc9848766D90cb4404124
BILLIONS_CREDENTIAL_QUERY_FILE=/path/to/query.json
Option C: Billions Testnet
For Billions testnet access, see Billions signup.
Testing Matrix
| Scenario | Implementation | Config |
|---|---|---|
| Unit tests | MockPrivadoVerifier | REQUIRE_PROOF_OF_HUMANITY=false |
| Dev local | MockPrivadoVerifier | REQUIRE_PROOF_OF_HUMANITY=false |
| E2E Amoy | Real verifier + own issuer | BILLIONS_ISSUER_DID=did:polygonid:... |
| Production | Real verifier + Billions | BILLIONS_ISSUER_DID=<billions-prod-did> |
Admin Authentication
Admin API endpoints (/api/v1/admin/*) use a separate authentication layer from the user auth flow described above. The admin middleware accepts two credential types:
X-Admin-Token (M2M / Bootstrap)
For machine-to-machine access and initial setup. Set via the ADMIN_API_TOKEN environment variable and passed in the X-Admin-Token request header.
curl -H "X-Admin-Token: $ADMIN_API_TOKEN" \
http://localhost:8080/api/v1/admin/orgs
JWT with Admin Claim (Browser-Based)
For admin dashboard access. Users authenticate normally (Privado ID or Azure AD), then the middleware checks whether the user has the admin RBAC claim via any of their group memberships. The check filters out expired memberships.
To grant admin access to a user:
- Create an organization and group via the admin API (using X-Admin-Token)
- Set the
adminclaim on the group's access configuration - Add the user as a member of that group
Dev mode shortcut
In development builds, mock-login users are automatically granted the admin claim on first login. No manual RBAC setup is needed — click the flask icon and access the admin dashboard immediately.
The frontend checks admin status via GET /api/v1/me/admin-status (returns { "is_admin": true/false }). Non-admin users see an "Access Denied" page; unauthenticated users are redirected to login.
Bootstrap the first admin with the X-Admin-Token (M2M) endpoint above, then grant the admin claim to subsequent admins through the RBAC steps just described.
Error Reference
Authentication Errors
| Status | Error | Cause |
|---|---|---|
| 400 | session parameter required | Missing session ID in callback |
| 400 | jwz_token required | Missing proof token |
| 401 | session not found or expired | Invalid or expired session (10 min TTL) |
| 401 | verification failed | Invalid ZK proof (the verbose "JWZ verification failed" is logged server-side only) |
| 403 | humanity_verification_required | User lacks PoH credential |
| 500 | VERIFIER_ID not configured | Missing configuration |
ETH Linking Errors
| Status | Error | Cause |
|---|---|---|
| 400 | invalid or expired nonce | Challenge expired (5 min TTL) |
| 400 | signature verification failed | Signature does not match address |
| 403 | challenge does not belong to this user | Wrong user for challenge |
| 404 | address not found | Address not linked to user |
Access Control Errors
| Status | Error | Cause |
|---|---|---|
| 401 | missing Authorization header | No Bearer token provided |
| 401 | invalid token | Malformed or invalid JWT |
| 403 | account is banned | Banned user on login / refresh (the admin dashboard returns user is banned) |
On the JSON-RPC data path, RBAC denials — method not allowed, KYC not completed, contract not permitted, and even a ban — are deliberately returned as an opaque 404 method not found; the proxy does not disclose why a call was denied. The operator-facing denial reasons are recorded in the access log and surfaced in Troubleshooting → RBAC.