facetec-api

A Go microservice that bridges the FaceTec biometric SDK (running
on a mobile device) with a verifiable-credential (VC) issuer. It acts as a security gateway:
biometric liveness and photo-ID scan results are validated against configurable SPOCP policy,
and — only on acceptance — a verifiable credential is issued through a gRPC back-end.
Mobile SDK → facetec-api → FaceTec Server (liveness + ID scan)
│
├── SPOCP policy engine (accept / reject)
│
└── vc issuer (gRPC) → SD-JWT / mDoc / VC 2.0
Biometric data (FaceMaps, raw scan images) never leaves process memory and is never written
to disk. See PRIVACY.md for a full data-flow and GDPR analysis.
Quickstart
# Build
make build
# Run with an example config (no TLS, no real FaceTec server)
cp configs/config.yaml configs/config.local.yaml
# Edit configs/config.local.yaml with your FaceTec Server URL, device key, and issuer address.
make run CONFIG=configs/config.local.yaml
Requirements
| Tool |
Version |
| Go |
≥ 1.25 |
| Make |
any |
| Docker (optional) |
≥ 25 |
Configuration
Configuration is loaded from a YAML file (default: configs/config.yaml) and can be overridden
by environment variables. The full annotated reference is configs/config.yaml.
Core settings
| YAML key |
Env variable |
Default |
Description |
server.host |
SERVER_HOST |
0.0.0.0 |
Listen address |
server.port |
SERVER_PORT |
8080 |
Listen port |
server.public_base_url |
SERVER_PUBLIC_BASE_URL |
(empty) |
Externally reachable base URL; used for credentialOfferURI |
server.tls.enabled |
SERVER_TLS_ENABLED |
false |
Enable TLS on the HTTP listener |
facetec.server_url |
FACETEC_SERVER_URL |
(required) |
FaceTec Server base URL |
facetec.device_key_path |
FACETEC_DEVICE_KEY_PATH |
(optional) |
File containing the FaceTec device key |
facetec.tls.ca_file |
FACETEC_TLS_CA_FILE |
(optional) |
CA certificate for outbound FaceTec TLS |
facetec.tls.skip_verify |
FACETEC_TLS_SKIP_VERIFY |
false |
Disable cert verification — never use in production |
issuer.addr |
ISSUER_ADDR |
(required) |
gRPC address of the vc issuer (e.g. issuer:8090) |
issuer.tls |
ISSUER_TLS |
false |
Enable TLS on the gRPC issuer connection |
issuer.scope |
ISSUER_SCOPE |
(required) |
Default credential scope URI |
issuer.format |
ISSUER_FORMAT |
sdjwt |
Default credential format: sdjwt, mdoc, vc20 |
policy.rules_dir |
POLICY_RULES_DIR |
(empty) |
Directory of .spoc rule files |
session.liveness_ttl |
SESSION_LIVENESS_TTL |
2m |
How long a FaceMap is held in memory |
session.offer_ttl |
SESSION_OFFER_TTL |
5m |
How long a credential offer is held in memory |
logging.production |
LOG_PRODUCTION |
false |
Enable JSON structured logging (recommended in prod) |
Numeric acceptance thresholds are encoded in the SPOCP rule files themselves rather than as
separate config keys. This keeps deployment policy in one place and allows per-tenant rule sets
to express score requirements such as liveness and face-match minima.
Authentication
Three modes are selected automatically based on which keys are present:
| Mode |
Config keys |
Behaviour |
| JWT (recommended) |
jwt.secret |
Validates HMAC-signed JWTs. The tenant_id claim selects the per-tenant policy; tokens without it use the default tenant. |
| Legacy Bearer |
security.app_key (no JWT secret) |
Constant-time comparison of a raw token. Single-tenant only. |
| Dev / unauthenticated |
neither set |
All requests are accepted. Never use in production. |
JWT settings:
| YAML key |
Env variable |
Default |
Description |
jwt.secret |
JWT_SECRET |
(empty) |
HMAC shared secret for HS256/384/512 |
jwt.secret_path |
JWT_SECRET_PATH |
(optional) |
File containing the JWT secret |
jwt.issuer |
JWT_ISSUER |
(empty) |
Expected iss claim; leave empty to skip validation |
jwt.require_auth |
JWT_REQUIRE_AUTH |
false |
Reject requests without a valid JWT |
security.app_key_path |
SECURITY_APP_KEY_PATH |
(optional) |
File containing a legacy Bearer token |
security.rate_limit.requests_per_minute |
SECURITY_RATE_LIMIT_RPM |
10 |
Per-IP rate limit on biometric endpoints |
Multi-tenant operation
When no tenants: block is present, a single default tenant is synthesised from the global
policy and issuer settings — existing deployments need no change.
When the tenants: block is present, each tenant is selected by the tenant_id JWT claim. A
missing or unconfigured tenant_id falls back to the default tenant (i.e., global settings),
so onboarding new tenants incrementally is safe.
Per-tenant overrides:
tenants:
- id: acme # must match JWT tenant_id claim
issuer:
scope: "https://credentials.acme.example.org/photo-id"
format: sdjwt
policy:
rules_dir: "/etc/facetec-api/rules/acme"
- id: gov
issuer:
scope: "https://credentials.gov.example.org/id-document"
format: mdoc
policy:
rules_dir: "/etc/facetec-api/rules/gov"
API
All /v1/* endpoints require a valid Authorization: Bearer <token> header when authentication
is configured. /livez and /readyz are always unauthenticated.
Probes (unauthenticated)
| Method |
Path |
Response |
GET |
/livez |
200 {"status":"ok"} — process is alive |
GET |
/readyz |
200 {"status":"ok"} when policy rules are loaded; 503 otherwise |
v1 API (authenticated)
| Method |
Path |
Description |
GET |
/v1/health |
Returns 200 {"status":"ok"} — useful as a credential smoke-test |
POST |
/process-request |
FaceTec-compatible requestBlob/responseBlob endpoint; augments successful matches with offer metadata |
POST |
/v1/process-request |
Versioned alias of /process-request |
POST |
/v1/session-token |
Obtain a short-lived FaceTec session token |
POST |
/v1/liveness |
Submit a liveness FaceScan; returns livenessSessionId |
POST |
/v1/id-scan |
Submit a photo ID scan; returns transactionId + credentialOfferURI |
GET |
/v1/offer/:txid |
Redeem a one-time credential offer (wallet pull) |
Biometric flow
Two mobile-facing contracts are supported:
- FaceTec-native processor flow via
/process-request or /v1/process-request
- Legacy split flow via
/v1/session-token, /v1/liveness, and /v1/id-scan
The FaceTec-native flow is the recommended integration for mobile SDKs that already use the sample
SessionRequestProcessor pattern.
FaceTec-native flow
1. POST /process-request
→ { "requestBlob": "...", "externalDatabaseRefID": "..." }
← { "responseBlob": "...", "result": { ... }, "transactionId": "...",
"credentialOfferURI": "openid-credential-offer://?..." }
2. GET /v1/offer/:transactionId
← { "credentials": ["<signed-token>"], "scope": "..." }
/process-request preserves FaceTec's upstream response JSON. When the upstream payload represents
a successful photo-ID match and local policy accepts it, facetec-api augments that response with
transactionId and credentialOfferURI. If policy or issuance fails, the response still contains
the upstream responseBlob and instead adds credentialIssueError.
Legacy split flow
1. POST /v1/session-token
← { "sessionToken": "…" }
2. POST /v1/liveness
→ { "sessionToken": "…", "faceScan": "<base64>", "auditTrail": […] }
← { "livenessSessionId": "…" }
3. POST /v1/id-scan
→ { "sessionToken": "…", "livenessSessionId": "…", "idScan": "<base64>",
"idScanFrontImagesCompressedBase64": […], … }
← { "transactionId": "…", "credentialOfferURI": "openid-credential-offer://…" }
4. GET /v1/offer/:transactionId
← { "credentials": ["<signed-token>"], "scope": "…" }
Steps 2 and 3 are independently rate-limited per source IP.
For FaceTec sample-app style integrations, use /process-request and send the
SDK-generated requestBlob unchanged. The service returns FaceTec's response JSON
unchanged, and on successful photo-ID matches also attaches transactionId and
credentialOfferURI so the wallet can redeem the issued credential.
SPOCP Policy Rules
Scan acceptance is a two-stage process:
- Score thresholds — encoded directly in SPOCP range predicates such as
(liveness-score (* range numeric ge 080)) and
(face-match-level (* range numeric ge 06)).
- Categorical rules (
.spoc files in policy.rules_dir) — S-expressions that encode which
combinations of document type and verification flags are acceptable.
Rules are loaded at startup (and re-loaded on SIGHUP). If the rules directory is empty or
policy.rules_dir is unset, the service starts but rejects all scans and reports not-ready.
; rules/default.spoc
; Accept passports with MRZ verification.
(facetec-scan (doc-type passport) (mrz-verified true))
; Accept e-passports with NFC chip verification.
(facetec-scan (doc-type passport) (mrz-verified true) (nfc-verified true))
; Accept driving licences with barcode verification.
(facetec-scan (doc-type dl) (mrz-verified false) (nfc-verified false) (barcode-verified true))
; Accept national ID cards (numeric thresholds from config still apply).
(facetec-scan (doc-type id_card))
Query fields available in every SPOCP query:
| Field |
Values |
doc-type |
passport, dl, id_card, unknown |
mrz-verified |
true, false |
nfc-verified |
true, false |
barcode-verified |
true, false |
Security
- Biometric data never touches disk. FaceMap templates are held exclusively in process
memory for the duration of the liveness→id-scan window (default 2 min); backing bytes are
zeroed with
clear() immediately after use and again on graceful shutdown.
- MRZ lines stripped before forwarding. Raw MRZ lines (encoding the full identity in
machine-readable form) are removed before any data is sent to the VC issuer.
- No request/response body logging. Gin runs in
gin.New() mode; the panic-recovery handler
is bound to nil to suppress recovery body dumps. RequestLogger records only method, path,
status, latency, client IP, and user agent.
- JWT authentication with HMAC HS256/384/512; signing method is explicitly validated to
prevent algorithm-confusion attacks. Legacy Bearer fallback uses constant-time comparison.
- Rate limiting. Biometric endpoints are rate-limited per source IP (default 10 rpm); stale
IP buckets are evicted every 5 minutes to prevent unbounded memory growth.
- Security response headers.
X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Cache-Control: no-store on every response.
- HTTP timeouts.
ReadHeaderTimeout: 10s, WriteTimeout: 90s, IdleTimeout: 120s.
- Production safety guard. With
logging.production: true, startup fails unless at least
one of jwt.secret or security.app_key is set.
- Atomic SIGHUP reload. The tenant registry is swapped atomically on SIGHUP; in-flight
requests are unaffected.
See PRIVACY.md for a full data-flow description and GDPR analysis.
Development
make tools # install golangci-lint, protoc plugins
make dev # build and run with hot config reload
make test # run all tests
make test-coverage # run tests with coverage profile + HTML report
make lint # run golangci-lint
make fmt # gofmt
make vet # go vet
Regenerating gRPC client code
The gRPC stubs under internal/gen/issuer/apiv1_issuer/ are copied from the vc workspace
sibling. Regenerate them after updating the vc proto source:
./scripts/generate-proto.sh
If protoc is available and you want to regenerate from the proto source directly:
REGEN=1 ./scripts/generate-proto.sh
Docker
make docker # build image
make docker-push # push to registry
docker compose up -d # run with compose.yaml
The Docker image is built on gcr.io/distroless/static-debian12 and runs as UID 65532
(non-root). Mount configuration and secrets as volumes or Docker secrets.
License
Apache-2.0 — see LICENSE.