Attestation policies
WebAuthn accepts any authenticator by default: YubiKeys, phones, password managers, browser-synced credentials. For most consumer apps that’s the right answer. For regulated ones it isn’t.
TryMellon’s attestation policy engine lets you define, per tenant, which authenticators can register. You can require hardware-backed keys only, pin specific models by AAGUID, block software authenticators, or set a minimum FIDO certification level. Policies run in either audit mode (log mismatches, accept all) or block mode (reject non-compliant registrations).
This is how you tell a private banking compliance team “only YubiKey 5 Series and Feitian K9” — and enforce it cryptographically, not on the honor system.
When to use it
| Situation | Policy configuration |
|---|---|
| Private banking / custody | allowed_aaguids = [YubiKey 5 Series, Feitian K9 family] · enforcement_mode: block |
| Regulated fintech (EU DORA, PCI) | min_certification_level: L2 · block_software_auth: true · enforcement_mode: block |
| Enterprise workforce | require_known_aaguids: true · blocklist consumer password managers |
| Consumer app ramping up | enforcement_mode: audit first, promote to block once telemetry confirms coverage |
| Rolling out a new authenticator model | Add to allowed_aaguids in audit mode; watch metrics; promote |
If you don’t need any of this, do nothing. The default is “accept any authenticator” — which is the right default for most SaaS.
How it works
Every WebAuthn registration produces an attestation statement — a cryptographic claim from the authenticator about its own identity: its AAGUID (Authenticator Attestation GUID, a UUID identifying the make/model), its certification level, and (for hardware devices) a chain rooted in the vendor’s CA.
TryMellon validates that chain against the FIDO MDS (Metadata Service), then evaluates it against your policy:
Registration ceremony
│
▼
Parse attestation statement
│
▼
Resolve AAGUID → MDS metadata
│
▼
┌─────────────────────────────┐
│ Policy evaluation │
│ • allowed_aaguids? │
│ • blocked_aaguids? │
│ • min_certification_level? │
│ • block_software_auth? │
│ • require_known_aaguids? │
└─────────────────────────────┘
│
┌────┴────┐
│ │
audit block
log+accept log+reject
Results are logged to your tenant audit log with the AAGUID, the matched/failed rule, and the evaluation mode — whether or not you block. Compliance gets a reviewable trail either way.
Policy shape
A tenant has exactly one policy. Updating it replaces the previous one.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (UUID) | yes | Your tenant ID. |
allowed_aaguids | string[] (UUIDs) | no | If set, only these AAGUIDs can register. |
blocked_aaguids | string[] (UUIDs) | no | These AAGUIDs are always rejected (even if also in allowed). |
min_certification_level | "L1" | "L2" | "L3" | "L3plus" | no | Minimum FIDO certification level — see FIDO Alliance levels. |
block_software_auth | boolean | yes | Reject software/virtual authenticators (e.g. browser-synced passkeys without hardware attestation). |
require_known_aaguids | boolean | yes | Reject authenticators whose AAGUID isn’t in the FIDO MDS. |
enforcement_mode | "audit" | "block" | yes | audit = log only · block = reject non-compliant. |
Rule precedence: blocked_aaguids > allowed_aaguids > per-flag checks (block_software_auth, require_known_aaguids) > min_certification_level.
Quick start
1. Set a policy (S2S)
POST https://api.trymellonauth.com/v1/attestation/policy
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json
{
"tenant_id": "ten_01HXYZ…",
"allowed_aaguids": [
"ee882879-721c-4913-9775-3dfcce97072a", // YubiKey 5 Series
"b92c3f9a-c014-4056-887f-140a2501163b" // YubiKey 5Ci
],
"blocked_aaguids": [],
"min_certification_level": "L2",
"block_software_auth": true,
"require_known_aaguids": true,
"enforcement_mode": "audit"
}
Start in audit mode. Watch the logs. Promote to block once you’re confident your users have compliant devices.
2. Read the current policy
GET https://api.trymellonauth.com/v1/attestation/policy?tenant_id=ten_01HXYZ…
Authorization: Basic <base64(client_id:client_secret)>
Response:
{
"ok": true,
"data": {
"policy": {
"id": "pol_…",
"tenant_id": "ten_…",
"allowed_aaguids": ["…"],
"blocked_aaguids": [],
"min_certification_level": "L2",
"block_software_auth": true,
"require_known_aaguids": true,
"enforcement_mode": "audit",
"created_at": "2026-04-17T10:00:00.000Z",
"updated_at": "2026-04-17T10:00:00.000Z"
}
}
}
If no policy is set, policy is null.
3. Evaluate a credential ad-hoc
Useful for dashboards and compliance reports — check an existing credential against the current policy without running a full registration ceremony.
POST https://api.trymellonauth.com/v1/attestation/evaluate
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json
{
"tenant_id": "ten_…",
"user_id": "user_…",
"aaguid": "ee882879-721c-4913-9775-3dfcce97072a"
}
Pass "aaguid": null to evaluate an authenticator that didn’t provide attestation.
API reference
POST /v1/attestation/policy
Create or replace the tenant’s policy.
- Auth: Basic Auth (S2S).
- Body: see Policy shape.
- Returns:
200 OK+ the saved policy.
GET /v1/attestation/policy
Read the tenant’s current policy.
- Auth: Basic Auth (S2S).
- Query:
tenant_id(UUID). - Returns:
{ policy: Policy | null }.
POST /v1/attestation/evaluate
Evaluate an AAGUID against the active policy without persisting.
- Auth: Basic Auth (S2S).
- Body:
{ tenant_id, user_id, aaguid }. - Returns:
{ passed: boolean, failed_rule?: string, level?: string }.
All three endpoints require the Attestation Policy Engine to be wired at deploy time. Self-hosted deployments without the engine return 501 Not Implemented.
Audit & telemetry
Every evaluation writes an audit entry:
{
"event_type": "attestation.evaluated",
"tenant_id": "ten_…",
"user_id": "user_…",
"aaguid": "ee882879-…",
"enforcement_mode": "audit",
"outcome": "pass",
"failed_rule": null,
"ts": "2026-04-17T10:15:00.000Z"
}
Filter for outcome: "fail" to find non-compliant registrations during audit-mode ramp-up. Use the audit log API — see Webhooks, audit & privacy.
Rollout playbook
- Inventory first. Query existing credentials via the admin API. Distribution by AAGUID tells you which devices are already in the wild.
- Audit mode. Set your policy with
enforcement_mode: audit. Run for 7–14 days. - Review logs. Group by
failed_rule. If >5% of registrations would be blocked, iterate: expandallowed_aaguids, relaxmin_certification_level, communicate with users who need new devices. - Promote to block. Flip
enforcement_mode: block. Existing credentials are not retroactively rejected; only new registrations are evaluated. - Monitor. Watch registration success rate and support volume for the next week. If anomalies spike, flip back to
audit, investigate, re-promote.
Error codes
| HTTP | Code | Cause |
|---|---|---|
400 | invalid_aaguid | A UUID in allowed_aaguids/blocked_aaguids is malformed. |
400 | invalid_enforcement_mode | Not "audit" or "block". |
401 | unauthorized | Bad Basic Auth. |
403 | forbidden | App lacks the attestation capability. |
501 | not_implemented | Attestation engine not wired in this deployment. |
Registration failures due to policy in block mode return:
| HTTP | Code | Cause |
|---|---|---|
403 | attestation_blocked_aaguid | Authenticator AAGUID is in blocked_aaguids. |
403 | attestation_aaguid_not_allowed | allowed_aaguids is set and AAGUID is not in it. |
403 | attestation_software_auth_blocked | block_software_auth: true and the credential has no hardware attestation. |
403 | attestation_unknown_aaguid | require_known_aaguids: true and AAGUID is not in FIDO MDS. |
403 | attestation_certification_level_below_minimum | Authenticator’s level is below min_certification_level. |