>_

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_URLhttps://billions-rpc.eu-north-2.gateway.fmRPC 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_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. 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:

  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.