>_

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

1

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.

2

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
3

Client submits proof

The proof is submitted to one of two endpoints:

POST
/auth/callback?session={session_id}

Wallet callback -- the standard flow

POST
/auth/verify

Manual 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.

4

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

1

Request challenge

POST
/eth/link/challenge

Request 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..."
}
2

Sign the message

The user signs the message with their Ethereum wallet (MetaMask, etc.) using personal_sign.

3

Submit signature

POST
/eth/link/verify

Submit signed challenge (JWT required)

{
  "nonce": "a1b2c3d4e5f6...",
  "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f...",
  "signature": "0x..."
}
4

View or unlink addresses

GET
/eth/addresses

List linked addresses (JWT required)

DELETE
/eth/addresses/:address

Unlink 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:

  1. Allows the link — shared keys are a legitimate pattern (multi-sig, shared deployer, team wallet).
  2. Emits a SIEM event with elevated severity (eth_address_linked_collision) so administrators can review.
  3. 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

VariableDefaultDescription
REQUIRE_PROOF_OF_HUMANITYfalseOpt-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_CONTRACT0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896On-chain identity state contract address (Privado mainnet default)
PRIVADO_CIRCUIT_IDcredentialAtomicQueryMTPV2iden3 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_TYPEProofOfHumanityCredential 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_URLhttps://rpc-mainnet.privado.idPrivado network RPC endpoint (backs the privado:main state resolver)
BILLIONS_RPC_URL(unset)RPC endpoint for the Billions identity chain (chainID 45056). Backs the billions:main state resolver so DIDs created in the Billions app can authenticate. Required for Billions sign-in — there is no default.
BILLIONS_STATE_CONTRACT0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896On-chain identity state contract for the Billions chain. Defaults to the shared iden3 cross-chain address; override only if Billions moves it.
IPFS_GATEWAYhttps://ipfs-proxy-cache.privado.idIPFS 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_URLhttp://localhost:8080Base URL used for callback URLs
ENVIRONMENTdevelopmentSet 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. privado:main is registered out of the box; billions:main requires you to set BILLIONS_RPC_URL. There is deliberately no default, because an RPC endpoint that stops resolving is worse than none at all — the resolver registers but cannot be reached, and every Billions sign-in then fails deep inside proof verification with nothing pointing at the missing setting.

While BILLIONS_RPC_URL is unset, billions:main is simply not registered. A Billions wallet's proof is refused immediately with network_not_supported naming the network, the failure is logged for the operator, and GET /api/v1/auth/providers omits the network so the login page advertises Privado ID alone rather than a sign-in that cannot complete.

BILLIONS_STATE_CONTRACT keeps its default, which stays correct on that chain, so enabling Billions is a one-variable change. The Privado resolver is configured by PRIVADO_RPC_URL and PRIVADO_STATE_CONTRACT. All of 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:

  1. Decided whether to enable Path B (credential check). If yes, set REQUIRE_PROOF_OF_HUMANITY=true and populate all of:
    • BILLIONS_ISSUER_DID
    • PRIVADO_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_TYPE
    • BILLIONS_CREDENTIAL_QUERY_FILE pointing to a valid JSON file The process refuses to start if REQUIRE_PROOF_OF_HUMANITY=true and any of these are missing or invalid.
  2. If Path B is not enabled, login will only prove DID ownership (no credential check). Users do not need a PoH credential to authenticate.
  3. Configured strong, unique JWT_SECRET and JWT_REFRESH_SECRET
  4. Configured VERIFIER_ID with your service's DID
  5. Set BASE_URL to your public URL
  6. Set ENVIRONMENT=production (disables the /auth/verify endpoint)

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 (isHuman true/false)
  • Works without any external infrastructure
  • Supports mock tokens: mock.{did} or mock.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:

ParameterValue
State contract0x1a4cC30f2aA0377b0c3bc9848766D90cb4404124
Chain ID80002
FaucetAlchemy 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

ScenarioImplementationConfig
Unit testsMockPrivadoVerifierREQUIRE_PROOF_OF_HUMANITY=false
Dev localMockPrivadoVerifierREQUIRE_PROOF_OF_HUMANITY=false
E2E AmoyReal verifier + own issuerBILLIONS_ISSUER_DID=did:polygonid:...
ProductionReal verifier + BillionsBILLIONS_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:

  1. Create an organization and group via the admin API (using X-Admin-Token)
  2. Set the admin claim on the group's access configuration
  3. 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

StatusErrorCause
400session parameter requiredMissing session ID in callback
400jwz_token requiredMissing proof token
401session not found or expiredInvalid or expired session (10 min TTL)
401verification failedInvalid ZK proof (the verbose "JWZ verification failed" is logged server-side only)
403humanity_verification_requiredUser lacks PoH credential
500VERIFIER_ID not configuredMissing configuration

ETH Linking Errors

StatusErrorCause
400invalid or expired nonceChallenge expired (5 min TTL)
400signature verification failedSignature does not match address
403challenge does not belong to this userWrong user for challenge
404address not foundAddress not linked to user

Access Control Errors

StatusErrorCause
401missing Authorization headerNo Bearer token provided
401invalid tokenMalformed or invalid JWT
403account is bannedBanned 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.

A rejected wallet proof

The wallet and the browser are two different clients. The wallet posts its proof to the callback endpoint and reads the outcome from that response; the browser only polls GET /api/v1/auth/session/{id}/status. When a proof is rejected, that poll now returns failed: true with a short reason, so the login page shows the failure straight away instead of waiting out its polling window and then reporting a timeout that never happened.

The reason comes from a fixed set and never carries internal detail:

reasonMeaningWhat the user should do
verification_failedThe proof did not verify against the authorization request.Generate a new QR code and retry. If it persists, check that the wallet's identity network is one this deployment supports.
humanity_requiredA ProofOfHumanity credential is required and was absent.Complete verification, then retry.
invalid_requestThe wallet's callback body was unreadable.Generate a new QR code and retry.
network_not_supportedThis deployment has no state resolver for the wallet's identity network, so the proof cannot be verified here.Nothing — retrying cannot help. The operator must enable that network, or the user must sign in with a wallet on a supported one.
authentication_failedAnything else, including a banned account and internal faults.Check the proxy log — the precise cause is recorded there.

A banned account and an internal fault both collapse to authentication_failed deliberately: the session ID is rendered in the on-screen QR code, so anyone who photographs it could otherwise poll for someone else's login outcome. Operators read the precise reason from the proxy log and from GET /api/v1/admin/sessions, which reports it unreduced.

network_not_supported is reported rather than collapsed because it discloses nothing that is not already public: the set of supported networks is published by GET /api/v1/auth/providers, which takes no authentication, and the code itself names no network. Withholding it only cost the person at the browser the one piece of information that would have told them the deployment — not their wallet — needs attention.

A rejection is not final, for the same reason. Because that session ID is visible, anyone who photographs the QR can post one bogus proof — so a failure must not be able to cancel a login that still succeeds. The login page shows the failure but keeps polling, and a wallet that retries successfully still completes the session. Any client polling this endpoint should do the same rather than treating failed: true as terminal.