Two questions, two endpoints.
“Is a live human here?” is a single POST — the proof-of-human API, for products that need a person rather than an account. “Who is this?” is plain OpenID Connect, so if your stack already speaks OIDC, pointing it here is a configuration change rather than an integration project.
Proof of human
The smallest useful question about a person, and the one most products actually need. You get a short-lived ES256 token from the browser and turn it into an answer with one authenticated POST. A proof is bound to your client, to your nonce and to a five-minute window, and it can be spent exactly once.
<script src="https://faceguid.com/sdk/faceguid.js"></script>
<script>
const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
const proof = await fg.proveHuman({ nonce }); // opens the ceremony, returns a token
await fetch('/api/human', { method: 'POST', body: JSON.stringify(proof) });
</script>
# your server, once:
curl -X POST https://faceguid.com/api/proof/verify \
-u "$FACEGUID_CLIENT_ID:$FACEGUID_CLIENT_SECRET" \
-H "content-type: application/json" \
-d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImZhY2VndWlkLXByb29mK2p3dCJ9...","nonce":"b7f2…"}'
# {"human": true, "sub": "6f1c2d0e-…", "verifications": 37, "enrolled_at": 1740873600, …}
Full reference, framework snippets and the server SDK: the SDK documentation →
| Endpoint | Auth | Purpose |
|---|---|---|
/verify | the person | The ceremony page the SDK opens. Returns the proof by postMessage or redirect. |
/api/proof/context | none | What the ceremony page renders: client name, whether the calling origin is registered. |
/api/proof/issue | face session | Mints the token. Only ever called by our own page, only after a passed ceremony. |
/api/proof/verify | client id + secret | The authoritative check. Verifies and spends the proof. 409 on a replay. |
/api/proof/redeem | client id + secret | Spend a jti you verified offline against the JWKS. |
Seal certificates
When someone seals a document with their face and publishes the certificate, that certificate is a public, dependency-free artefact. Fetch it, hash the file you were given, check the signature. You never talk to us again, and it keeps verifying after we are gone.
const cert = await (await fetch(`https://faceguid.com/api/seal/${documentId}`)).json();
const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const hash = btoa(String.fromCharCode(...new Uint8Array(
await crypto.subtle.digest('SHA-256', fileBytes))));
const key = await crypto.subtle.importKey('spki', b64(cert.publicKey),
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const signed = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key,
b64(cert.signature), new TextEncoder().encode(cert.statement));
// All three, or it is not sealed.
const ok = signed
&& hash === cert.contentHash
&& cert.statement === ['faceguid/seal/v1', cert.faceguid, cert.documentId,
cert.contentHash, cert.sealedAt].join('|');
An unpublished certificate and a non-existent one both answer
404, deliberately: this endpoint is not an oracle for which documents exist.
Human-readable verification lives at /seal?id=….
https://your-domain/.well-known/faceguid.txt and verify it. A client can only
redirect to, or run the SDK on, a host you have proved — which is what stops this domain
being used to bounce people somewhere else. localhost needs no proof, so you can
build first and verify before you ship.client_id and a secret shown exactly once.https://faceguid.com/.well-known/openid-configurationsub as your user id.
| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/openid-configuration | GET | Discovery document. |
/.well-known/jwks.json | GET | ES256 public key for verifying id_tokens. |
/authorize | GET | Consent + liveness check. Redirects back with code. |
/api/oauth/token | POST | Exchange the code for an access token and id_token. |
/api/oauth/userinfo | GET | Claims permitted by the granted scopes. |
/api/oauth/introspect | POST | Check an access token is still live. |
/api/oauth/revoke | POST | Revoke an access token. |
/api/proof/verify | POST | Proof of human: verify and spend a proof token. |
/sdk/faceguid.js | GET | The browser SDK. Served from this origin, CORS-open. |
/api/seal/{document_id} | GET | A published seal certificate. Public, CORS-open, no credentials. |
/api/domains/verify | POST | Prove control of a host so your clients may redirect to it. |
| Scope | Claims | Notes |
|---|---|---|
openid | sub, amr, acr, auth_time |
Always granted. Proves a live human passed the check. |
faceguid | faceguid |
The raw GUID. Only released when the app has pairwise subjects turned off. |
profile | name, faceguid_enrolled_at, faceguid_verifications |
Enrolment age is a useful signal against throwaway identities. |
email | email, email_verified |
Only ever a verified address. |
phone | phone_number, phone_number_verified |
Only ever a verified number. |
links | links[] |
Verified websites and social profiles, each with proof of control. |
sub is HMAC(server key, client_id ‖ faceguid) — stable for you, useless
to anyone else. Turn it off only if you genuinely need the cross-service identifier.import { Issuer, generators } from 'openid-client';
const faceguid = await Issuer.discover('https://faceguid.com');
const client = new faceguid.Client({
client_id: process.env.FACEGUID_CLIENT_ID,
client_secret: process.env.FACEGUID_CLIENT_SECRET,
redirect_uris: ['https://example.com/auth/callback'],
response_types: ['code'],
});
// 1. send the user to FaceGUID
app.get('/auth/login', (req, res) => {
const verifier = generators.codeVerifier();
req.session.verifier = verifier;
res.redirect(client.authorizationUrl({
scope: 'openid faceguid email',
code_challenge: generators.codeChallenge(verifier),
code_challenge_method: 'S256',
state: generators.state(),
}));
});
// 2. they come back with a code
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokens = await client.callback(
'https://example.com/auth/callback', params,
{ code_verifier: req.session.verifier, state: req.session.state });
const claims = tokens.claims();
// claims.sub -> stable identifier for this human, in your app
// claims.amr -> ['face', 'liveness']
req.session.user = claims.sub;
res.redirect('/');
});
# exchange the code
curl -X POST https://faceguid.com/api/oauth/token \
-u "$FACEGUID_CLIENT_ID:$FACEGUID_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://example.com/auth/callback
# {
# "access_token": "...",
# "token_type": "Bearer",
# "expires_in": 3600,
# "scope": "openid faceguid email",
# "id_token": "eyJhbGciOiJFUzI1NiIs..."
# }
# read the profile
curl https://faceguid.com/api/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
// A Cloudflare Worker verifying a FaceGUID id_token, no dependencies.
const JWKS = 'https://faceguid.com/.well-known/jwks.json';
async function verifyIdToken(jwt, clientId) {
const [h, p, s] = jwt.split('.');
const header = JSON.parse(atob(h.replace(/-/g,'+').replace(/_/g,'/')));
const payload = JSON.parse(atob(p.replace(/-/g,'+').replace(/_/g,'/')));
const { keys } = await (await fetch(JWKS)).json();
const jwk = keys.find(k => k.kid === header.kid);
const key = await crypto.subtle.importKey('jwk', jwk,
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const sig = Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig,
new TextEncoder().encode(`${h}.${p}`));
if (!ok) throw new Error('bad signature');
if (payload.iss !== 'https://faceguid.com') throw new Error('bad issuer');
if (payload.aud !== clientId) throw new Error('bad audience');
if (payload.exp * 1000 < Date.now()) throw new Error('expired');
return payload; // payload.sub is your user
}
amr: ["face","liveness"] |
Authentication was a face match plus a speak-the-digits liveness challenge. There is no password anywhere in this flow. |
acr: "faceguid:liveness:speak-digits" |
Names the exact ceremony used, so you can require it and reject weaker ones if the set ever grows. |
auth_time |
When the face check actually happened. Codes expire in 120 seconds, so this is always recent. |
Worth knowing before you make FaceGUID your only factor:
- Liveness analysis runs in the user’s browser. The challenge digits are server-issued
and single-use, which stops replay, but a determined attacker controlling the client can
report whatever evidence they like. Treat
amras a strong signal, not a proof. - Face recognition has a false-match rate. The threshold here is tuned tight (0.45 on L2-normalised 128-D descriptors), but identical twins are a known limitation of every face biometric, this one included.
- For high-value actions, pair FaceGUID with something the user knows or holds. The vault supports exactly that with an optional passphrase.