NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Attestation is how an authenticator proves what it is. It is off by default — the format is recorded, the statement is never read — and turning it on is two config lines:

PASSKEYS_ATTESTATION=direct        # ask authenticators to attest
PASSKEYS_ATTESTATION_TRUST=basic   # and refuse anything unproven

Ceremony call sites don’t change at all — attestation hardening is configuration, not code.

Synced passkeys never attest. iCloud Keychain, Google Password Manager and most password managers answer even a direct request with fmt none — a key that moves between devices has no single device to vouch for. Under self or basic every such passkey is refused with AttestationRequired, which in practice means every passkey an unmanaged iPhone, iPad or Mac creates today. The strict tiers are for fleets of device-bound authenticators (security keys, managed devices); keep ignore, the default, for consumer sign-in.

What changes is what you can see afterwards:

$passkey->attestation_format;   // 'packed' | 'apple' | 'none' — under `ignore`, any format the authenticator named
$passkey->attestation_type;     // 'basic' | 'anonca' | 'self' | 'none' — the grade of proof established

The recorded fmt is always a well-formed WebAuthn format identifier — at most 32 printable ASCII characters, with no double quote or backslash. Anything else is refused with InvalidClientData under every tier, ignore included.

The trust ladder

attestation_trustWhat it accepts
ignoreEverything (the default). The format is recorded; no statement is ever read.
selfThe statement’s maths must hold — signature, chain linkage and CA constraints, certificate validity dates. Anchoring is waived, so self-attestation and an un-anchored batch certificate both pass. A none statement — every synced passkey — is refused.
basicThe maths must hold and the certificate chain must reach a configured trust anchor. Self-attestation is refused.

Setting attestation_trust above ignore while attestation is none fails at boot, not at the first lost registration. Under ignore, reject_unknown_fmt additionally refuses a format the package can’t verify and a known format whose statement doesn’t verify — trust anchors are still not consulted.

Supported formats

fmtWho sends itType established
noneEverything unless you ask for direct — and synced passkeys always (iCloud Keychain, Google Password Manager, most password managers)none
packedCTAP2 security keys; MDM-managed Apple devices with Apple’s Passkey Attestation configurationbasic (with x5c) or self (without)
appleOlder device-bound Touch ID / Face ID credentials, from before passkeys synced through iCloud Keychain (iOS 16 / macOS 13)anonca

Any other format under self or basic is refused by name with UnsupportedAttestationFormat. Apple attests anonymously: its statement carries no signature over the ceremony — the credential certificate instead carries a nonce equal to SHA-256(authenticatorData ‖ clientDataHash) and certifies the credential’s own public key. Both are verified, so a statement from another ceremony is unusable here. The grade it establishes is anonca — “a genuine Apple authenticator”, never a device identity.

Trust anchors

A chain is anchored when its last certificate is an anchor or is signed by one (x5c commonly omits the root). Every certificate that signed another one — each x5c entry above the leaf, and the anchor when it completes the chain — must be a certificate authority (RFC 5280: basicConstraints CA:TRUE, and keyCertSign when it carries a keyUsage extension), and every pathLenConstraint must hold. So anchoring your organisation’s CA trusts the attestation CAs it issues — never an end-entity certificate it issued signing “attestation” leaves of its own. Security keys attest under their vendor’s own root, so supply it:

'attestation_anchors' => [
    'paths' => ['packed' => [storage_path('webauthn/vendor-fido-ca.pem')]],
],

The package ships Apple’s published WebAuthn Root CA and Google’s published hardware-attestation roots (trusted unless PASSKEYS_ATTESTATION_DEFAULT_ANCHORS=false), with every fingerprint pinned in its test suite. An apple-format statement therefore anchors under basic with no setup — Apple omits the root from its x5c and the shipped anchor completes the chain. That covers only the older device-bound credentials; synced iCloud Keychain passkeys carry no statement at all. The Google roots are for the android-key format, which is not verified yet and is refused under self or basic.

Managed Apple devices can attest: with Apple’s Passkey Attestation declarative configuration (MDM; iOS/iPadOS 17, macOS 14), passkeys created for the listed relying-party domains carry a packed statement signed with a certificate identity your MDM provisions. That chain ends at your organisation’s CA, not Apple’s WebAuthn root — add it under attestation_anchors.paths.packed.

Every rejection names the format, the offending value and the config key that fixes it:

The "packed" attestation chain's root ("CN=Vendor Batch 7, O=Vendor", sha256 9f3ae1c2…,
issued by "CN=Vendor FIDO Root CA, O=Vendor") is not among the configured trust anchors.
Add the issuing CA's PEM to passkeys.attestation_anchors.paths.packed.

With no anchor configured for the format at all — a security key under the defaults, which ship no packed roots — the refusal still names the issuing CA to fetch.

Certificate validity dates

Attestation certificates are held to both bounds of their validity window, with attestation_clock_skew seconds of leeway (default 60, range 0–3600). This refuses real hardware: an authenticator whose batch certificate has lapsed can no longer enrol under self or basic. That is deliberate — an expired chain is not something a relying party should silently bless — but plan for it.

AAGUID allow-list

PASSKEYS_AAGUIDS_ALLOWED=d8522d9f-575b-4866-88a9-ba99fa02f35b

Empty (the default) allows every authenticator model. The AAGUID is only proven under basic, where the batch certificate binds it; under the lower tiers the authenticator merely asserts it — the list is still enforced when configured, ignore included.

Under the default PASSKEYS_ATTESTATION=none, browsers replace a security key’s AAGUID with zeros (stored as null — no model disclosed), so an allow-list refuses every security key until you request direct. Synced passkeys keep their provider AAGUID either way.

Catching failures

Forgeries and policy refusals are separately catchable, so they are never confused. All three extend PasskeyException:

use RoundlyConsulting\Passkeys\Exceptions\AttestationRequired;
use RoundlyConsulting\Passkeys\Exceptions\AttestationUntrusted;
use RoundlyConsulting\Passkeys\Exceptions\InvalidAttestation;

try {
    $passkey = Passkeys::for($user)->register($response);
} catch (InvalidAttestation $e) {
    // the maths failed — malformed statement, bad signature, algorithm or AAGUID mismatch
} catch (AttestationUntrusted $e) {
    // sound statement, refused by policy — unanchored root, invalid certification path,
    // expired certificate, AAGUID not allowed
} catch (AttestationRequired $e) {
    // the tier demands a statement and the authenticator sent 'none'
}

Testing your policy

Point the anchors at a throwaway chain and any packed happy path becomes a short test:

file_put_contents($path, $chain->root()->pem());
config()->set('passkeys.attestation_anchors', ['defaults' => false, 'paths' => ['packed' => [$path]]]);

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.