SansID lets a service answer one narrow question, such as "is this person at least 18?", without receiving a name, birthdate, document image, selfie, phone number, account identifier, or reusable credential.
It is a practical replacement for repeated document-upload age checks. A trusted issuer checks the real-world evidence once. Afterwards, a person can prove only age_over_18 to any service that trusts that issuer. Every proof is fresh, specific to one website, and unusable on another website or for a later request.
A service does not pair with each person. It pairs once with an issuer it has chosen to trust, by configuring that issuer's public key. Then its backend can ask a visitor for an age proof and make an allow or deny decision locally.
| Party | Its job | What it learns or keeps |
|---|---|---|
| Person | Holds a credential in the SansID app | Their private holder secret and the credential envelope |
| Issuer | Checks age once and signs a narrow credential | Whatever evidence its issuance policy requires, but not the holder secret |
| Service | Requests age_over_18 when it is needed |
A one-time proof and its own allow or deny decision |
| SansID holder app | Obtains consent and makes the proof | The private holder secret never leaves the device |
There is no ongoing issuer call, no central presentation relay, and no per-service identity profile. A service does not need a mobile-phone number to verify a proof. A phone may be used only at the issuer's one-time issuance step if that issuer chooses it as evidence.
Most age gates make every person upload an identity document, often with a selfie, to a website or its verification vendor. That gives another organization a permanent, transferable copy of high-value identity material just to learn a yes or no fact. Encryption cannot make that collection minimal.
SansID moves the evidence check to issuance and makes later verification a proof of one fact.
| Document-upload model | SansID model |
|---|---|
| Each site collects or outsources a document and often a selfie. | One issuer checks evidence once. |
| Sites and vendors accumulate documents, birthdates, and biometrics. | Services receive only age_over_18. |
| A copied document can be reused in another verification flow. | A copied proof fails for another website or a new request. |
| Repeated checks can create an identity trail across services. | Fresh BBS+ randomization prevents a stable proof-level identifier. |
The diagrams are deliberately split. Issuance happens once. A presentation happens whenever a service needs an answer.
flowchart LR
P["Person"] -->|"shows age evidence once"| I["Trusted issuer"]
I -->|"single-use issuance challenge"| H["SansID holder app"]
H -->|"blind request\ncommitment + proof of knowledge"| I
I -->|"blind BBS+ credential\npredicate: age_over_18"| H
H -->|"encrypted local storage"| D["Credential envelope"]
S["Private holder secret\ncreated on the device\nnever sent to the issuer"] --> H
- The person proves their age to a trusted issuer. This could be in person, through a regulated digital-identity flow, or with another issuer-approved method.
- The holder app creates a random private holder secret on the person's device.
- The app sends the issuer a blind request. It proves that the app knows a secret, without showing that secret.
- After the age check succeeds, the issuer signs an
age_over_18credential. The app verifies and stores it locally in an encrypted envelope.
The issuer never needs the holder secret. That is what stops it from recognizing later proofs made with the credential.
flowchart LR
B["Service backend"] -->|"proof request\norigin + age_over_18 + fresh nonce"| W["Website or host app"]
W -->|"origin-authenticated bridge"| H["SansID holder app"]
H -->|"shows: site.example requests\nproof that you are 18+"| U["Person approves"]
U -->|"device authorization"| K["TPM, Secure Enclave,\nKeystore, or token"]
K -->|"opens local credential envelope"| H
H -->|"fresh presentation\nproof + origin + nonce + issuer key id"| W
W -->|"normal HTTPS"| B
B -->|"checks pinned issuer key,\nproof, origin, and unused nonce"| A["Allow or deny"]
The backend creates the request. The browser or host app passes it to the user's holder app through a trusted native integration. The holder app shows the actual website and the requested fact, gets local approval, and returns a new proof. The backend verifies it without contacting the issuer.
The verifier-side protocol has only two network messages. The website can use ordinary HTTPS to its own backend. The native holder integration is responsible for delivering the request to the holder app and returning the presentation.
Before accepting proofs, the service administrator obtains the issuer's public BBS+ key through an authenticated business or trust-registry process and pins it in backend configuration. This is not a secret and is not a per-user pairing.
{
"issuer_name": "Example Age Issuer",
"environment": "production",
"issuer_key_id": "the issuer key identifier published by the issuer",
"issuer_public_key": "canonical base64url BBS+ public key",
"allowed_predicates": ["age_over_18"]
}The service must never accept an issuer key only because it appeared in a holder's presentation. It uses this pinned configuration to decide which issuers can answer its age question. During key rotation, configure the new issuer key before accepting credentials issued under it, then retire the old key by policy.
For each attempt, the backend creates a new 32-byte random nonce, stores it as unused, and sends this shape to the website or host app:
{
"request_id": "server-side request record",
"origin": "https://service.example",
"predicate": "age_over_18",
"nonce": "base64url of exactly 32 random bytes"
}request_id is application metadata. origin, predicate, and nonce are cryptographically bound into the proof. The holder app must receive the browser-observed origin from a native bridge, not an origin that page JavaScript can invent.
The holder returns the standard SansID presentation JSON. The frontend forwards it to the service backend. In Rust, backend verification is deliberately small:
let request = ProofRequest::from_base64(origin, Predicate::AgeOver18, nonce)?;
let presentation = Presentation::from_json(presentation_json)?;
let trusted_issuer = IssuerPublicKey::from_base64(configured_issuer_public_key)?;
verify(&presentation, &request, &trusted_issuer)?;
consume_nonce_once(request_id)?;
allow_access();The server must atomically consume the nonce when verification succeeds. It must reject a proof that has the wrong origin, age predicate, nonce, protocol profile, issuer key, or cryptographic proof.
See the complete verifier integration guide and the executable verifier example for the request, trust record, and response flow.
The holder is a native user application, not a cloud identity middleman. At issuance it stores the public credential envelope locally. For each presentation, it asks the operating system to authorize access to the envelope and creates the BBS+ proof locally.
| Platform | Holder form | Native protection |
|---|---|---|
| Linux | Signed user application and systemd --user service |
TPM 2.0 or PKCS#11 token |
| Windows | Signed per-user application and named pipe | CNG Microsoft Platform Crypto Provider backed by TPM |
| macOS | Signed and notarized app with LaunchAgent | Keychain access control and Secure Enclave key |
| Android | Native application | Android Keystore in Trusted Environment or StrongBox |
| iOS and iPadOS | Native application or approved extension | Keychain access control and Secure Enclave key |
TPMs, Secure Enclaves, and hardware tokens protect a non-exportable envelope key and require user authorization. They do not usually run BLS12-381 proof arithmetic themselves. The holder app performs the proof after the hardware gate opens the envelope for that one approved request, then zeroizes the released secret.
The Rust crate in this repository is the cryptographic core and the native-adapter contract. A production holder package supplies the operating-system specific application, installer, browser bridge, and hardware adapter described in platform integration.
- Fixed BLS12-381 BBS+ profile with no runtime algorithm negotiation.
- Blind issuance so the issuer signs a commitment without learning the holder secret.
- A proof is bound to one canonical HTTPS origin and one exactly 32-byte verifier nonce.
- The verifier pins the issuer public key locally before accepting a proof.
- Every proof uses fresh randomness, so proof bytes do not become a stable holder identifier.
- The proof reveals the fixed schema and
age_over_18, not a birthdate, ID image, credential signature, or holder secret.
SansID makes the proof payload private. A service can still track its users through accounts, cookies, IP addresses, device fingerprints, payment data, or logging. Privacy-respecting deployments minimize those surrounding signals too.
cargo test --all-targets
cargo run --example local_test_issuer
cargo run --example verifier_integration
cargo run --example age_gate
cargo run --example transportAll examples create ephemeral local test material. The local test issuer certificate is a verifier trust record created for that process only. It is not a real-world identity credential or production trust registry.
- Verifier integration guide
- One-time issuance and proof details
- Wire and cryptographic protocol
- Platform integration and installation
- Why document upload is the wrong privacy boundary
- Security policy and deployment requirements
- Contribution guide
Licensed under the Apache License 2.0.