Validating Attestation Statements on the Server
Attestation statement validation is the step in designing secure registration endpoints where the server cryptographically verifies that a credential was generated by a genuine, policy-compliant authenticator. This page isolates that sub-problem: exact error signatures you will encounter, the four discrete failure modes that cause them, and a step-by-step resolution path with copy-pasteable code. For the broader backend verification and secure credential storage context — including how the verified credential is subsequently stored — follow the parent series.
Attestation Data Flow
The diagram below shows the four boundaries the server must cross during attestation validation. A fault at any boundary surfaces as one of the error codes in the reference table that follows.
Exact Error Signatures
Match your server log or HTTP response against this table before reading further.
| Error Code | HTTP Status | Trigger Condition | Diagnostic Command |
|---|---|---|---|
WEBAUTHN_ERR_ATTESTATION_NOT_SUPPORTED |
400 |
Client sends none attestation when server policy requires packed, tpm, or android-key |
grep -r "attestation: 'none'" src/ |
CERT_CHAIN_VERIFICATION_FAILED |
422 |
Root CA mismatch, expired intermediate, or missing cross-sign in the x5c array |
openssl x509 -in attestation.pem -text -noout | grep -E "Issuer|Not After" |
AAGUID_NOT_IN_MDS |
409 |
Authenticator AAGUID absent from FIDO MDS3 registry (stale local trust store) | Query MDS3 blob for the AAGUID via jq '.entries[] | select(.aaguid=="<value>")' |
SIGNATURE_VERIFICATION_FAILED |
401 |
Cryptographic signature over authData || SHA-256(clientDataJSON) fails against the attestation certificate public key |
openssl dgst -sha256 -verify pubkey.pem -signature sig.bin signed_data.bin |
UNSUPPORTED_ALGORITHM |
422 |
COSE alg parameter outside the server allowlist (e.g. -37 RS384 instead of -7 ES256) |
Log info.credential.publicKeyAlgorithm before the allowlist check |
Security rule: Never return raw CBOR payloads or certificate bytes in 4xx responses. Emit a sanitised error code and a request_id for log correlation.
Root Cause Analysis
1. Malformed or mis-encoded attestationObject
The attestationObject arrives as a base64url string. Decoders that strip padding incorrectly, or that attempt to parse the raw bytes as UTF-8, silently corrupt the CBOR structure before any crypto is attempted. The resulting parse error looks like Unexpected token or unexpected major type.
Trigger condition: The client library base64url-encodes with standard padding (=) but the server decoder expects unpadded input, or vice versa.
2. Incorrect COSE key parsing
The credentialPublicKey inside authData is a COSE key map (RFC 8152). CBOR decoders that default to treating the buffer as PEM/DER will fail or return ERR_OSSL_EVP_UNSUPPORTED when the key is passed to OpenSSL. The algorithm identifier map key 3 (integer) must be decoded to -7, -257, or -8 before key instantiation.
Trigger condition: Using a generic CBOR decoder without COSE-aware post-processing. The alg field reads as undefined and the subsequent createVerify() call throws.
3. MDS3 trust store staleness
The FIDO Alliance MDS3 publishes attestation root certificates, AAGUID entries, revocation lists, and security assertions as a signed JWT blob. A cached copy older than 24 hours may not include AAGUIDs for recently released authenticators, causing AAGUID_NOT_IN_MDS rejections for legitimate devices.
Trigger condition: Background sync job disabled or misconfigured; JWT expiry not checked before use; stale blob served from a CDN without a cache-busting strategy.
4. RP ID hash mismatch
Bytes 0–31 of authData contain SHA-256(rpId). The server computes SHA-256(expectedRpId) and compares. Mismatches occur when the frontend specifies a subdomain for rp.id that differs from the backend configuration, or when the protocol prefix (https://) is accidentally included in expectedRpId.
Trigger condition: Frontend sets rp.id = "auth.example.com" but backend computes SHA-256("example.com"); or expectedRpId is set to "https://example.com" instead of "example.com".
Step-by-Step Resolution
Step 1 — Decode the attestationObject
Extract fmt, attStmt, and authData from the CBOR-encoded payload. Use the cbor package; do not attempt manual binary parsing.
import cbor from 'cbor';
async function decodeAttestationObject(attestationObjectB64url: string) {
// Strip any padding, then decode
const buf = Buffer.from(attestationObjectB64url, 'base64url');
const decoded = cbor.decodeFirstSync(buf) as {
fmt: string;
attStmt: Record<string, unknown>;
authData: Buffer;
};
if (!decoded.fmt || !decoded.authData) {
throw new Error('Malformed attestationObject: missing fmt or authData');
}
return decoded;
}
Verify the round-trip: cbor.encodeOne(decoded) should produce an identical buffer.
Step 2 — Parse and validate authData flags
Byte 32 (zero-indexed) of authData is the flags byte. For a registration ceremony, bit 6 (AT, attested credential data present) and bit 0 (UP, user presence) must both be set. Bit 2 (UV, user verification) is required when your server policy sets userVerification: 'required'.
function parseAuthDataFlags(authData: Buffer): {
up: boolean; uv: boolean; at: boolean; rpIdHash: Buffer
} {
if (authData.length < 37) {
throw new Error('authData too short — minimum 37 bytes for registration');
}
const flags = authData[32];
return {
rpIdHash: authData.subarray(0, 32),
up: (flags & 0x01) !== 0, // bit 0
uv: (flags & 0x04) !== 0, // bit 2
at: (flags & 0x40) !== 0, // bit 6
};
}
Then verify rpIdHash:
import { createHash } from 'crypto';
function verifyRpIdHash(rpIdHash: Buffer, expectedRpId: string): void {
// expectedRpId must be a bare hostname — no protocol, no trailing slash
const expected = createHash('sha256').update(expectedRpId, 'utf8').digest();
if (!rpIdHash.equals(expected)) {
throw new Error(
`RP ID hash mismatch — check that rp.id matches on client and server`
);
}
}
Step 3 — Validate the certificate chain against MDS3
Cross-reference the x5c certificate chain with the FIDO Alliance MDS3 trust anchors for the attested AAGUID. Run this shell check during local debugging; in production the vetted library handles it internally.
# Export the root certificate bundle from your local MDS3 cache
jq -r '.entries[] | select(.aaguid=="08987058-cadc-4b81-b6e1-30de50dcbe96")
| .metadataStatement.attestationRootCertificates[]' mds3.json \
| while read cert; do
printf -- "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n" "$cert"
done > fido_roots.pem
# Verify the attestation certificate chain
openssl verify -CAfile fido_roots.pem -untrusted intermediate.pem attestation_cert.pem
Keep your MDS3 blob fresh. Implement a background sync:
import { createVerify } from 'crypto';
async function syncMds3(localCachePath: string): Promise<void> {
const resp = await fetch('https://mds.fidoalliance.org/');
const jwt = await resp.text();
// Validate the JWT signature using the MDS3 root certificate
const [headerB64, payloadB64, sigB64] = jwt.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
const sig = Buffer.from(sigB64, 'base64url');
// mds3RootKey is loaded from the pinned MDS3 root cert
const verifier = createVerify('sha256');
verifier.update(signingInput);
if (!verifier.verify(mds3RootKey, sig)) {
throw new Error('MDS3 JWT signature invalid — do not update local store');
}
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
await fs.writeFile(localCachePath, JSON.stringify(payload), 'utf8');
}
Step 4 — Verify the attestation signature
For packed attestation (the most common format), the signature in attStmt.sig covers authData || SHA-256(clientDataJSON) and is produced by the attestation certificate’s private key.
import { createHash, createVerify } from 'crypto';
import { X509Certificate } from 'crypto';
function verifyPackedAttestationSignature(
authData: Buffer,
clientDataJSON: Buffer,
attStmt: { sig: Buffer; x5c: Buffer[] },
): void {
const cert = new X509Certificate(attStmt.x5c[0]);
const pubKey = cert.publicKey;
const clientHash = createHash('sha256').update(clientDataJSON).digest();
const verifyData = Buffer.concat([authData, clientHash]);
const verifier = createVerify('sha256');
verifier.update(verifyData);
if (!verifier.verify(pubKey, attStmt.sig)) {
throw new Error('SIGNATURE_VERIFICATION_FAILED');
}
}
Step 5 — Production integration with @simplewebauthn/server
Steps 1–4 illustrate the internals. In production, delegate to a vetted library that implements all of them atomically with correct CBOR/COSE handling, algorithm allowlisting, and MDS3 integration:
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
const ALLOWED_ALGORITHMS: number[] = [-7, -257, -8]; // ES256, RS256, EdDSA
export async function verifyAttestation(
registrationResponse: RegistrationResponseJSON,
expectedChallenge: string,
expectedRpId: string,
) {
const result = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge,
expectedOrigin: `https://${expectedRpId}`,
expectedRPID: expectedRpId,
requireUserVerification: true,
});
if (!result.verified || !result.registrationInfo) {
throw new Error('Attestation verification failed');
}
const { credential, aaguid, attestationObject } = result.registrationInfo;
if (!ALLOWED_ALGORITHMS.includes(credential.publicKeyAlgorithm)) {
throw new Error(
`UNSUPPORTED_ALGORITHM: ${credential.publicKeyAlgorithm} is not permitted`
);
}
return {
credentialId: credential.id,
publicKey: credential.publicKey,
signCount: credential.counter,
aaguid,
attestationType: attestationObject.fmt,
};
}
Verification and Testing
Confirm each fix with these one-liners and test vectors before deploying.
Unit test — flags byte assertions:
import assert from 'node:assert/strict';
// authData bytes 0–36: 32 bytes rpIdHash + 1 flags + 4 signCount
const flags = Buffer.alloc(37);
flags[32] = 0x45; // AT=1, UV=1, UP=1
const parsed = parseAuthDataFlags(flags);
assert.equal(parsed.at, true, 'AT flag must be set for registration');
assert.equal(parsed.uv, true, 'UV flag must be set when userVerification=required');
assert.equal(parsed.up, true, 'UP flag must always be set');
Conformance tool — FIDO Conformance Test Suite:
# Run the packed attestation test vectors from the FIDO Alliance conformance tools
npx @simplewebauthn/server-conformance-test \
--server http://localhost:3000 \
--test-set packed
OpenSSL — manual signature check:
# Reproduce Step 4 outside your server process
openssl dgst -sha256 -verify pubkey.pem \
-signature sig.bin \
<(cat authdata.bin clientdatahash.bin)
# Expected: "Verified OK"
MDS3 sync health check:
# Confirm your cached blob is current (legalHeader includes the update date)
jq '.legalHeader, .nextUpdate' /var/cache/mds3.json
# nextUpdate should be in the future; if not, the sync job is broken
Pitfalls
1. Skipping COSE algorithm allowlist enforcement. Calling createVerify() without first checking credential.publicKeyAlgorithm against a known-good list allows an attacker to negotiate a weak or unsupported algorithm (-65535, 0). Always enforce the allowlist before instantiating any cryptographic primitive. See cryptographic algorithms supported by WebAuthn for the current recommended set.
2. Treating none attestation as equivalent to verified attestation. When fmt === 'none', there is no certificate chain and no AAGUID — the server receives a credential with zero provenance information. Logging PASS for a none attestation alongside a packed one produces misleading audit records. Log the attestationType separately and apply policy decisions (e.g. MDS3 lookup, AAGUID allowlist) only when fmt !== 'none'.
3. Re-using the same challenge across retries. If a registration attempt fails client-side and the user retries, the server must issue a fresh challenge. Accepting the original challenge for a second attestationObject opens a replay window. Store challenges with a short TTL (90 seconds per FIDO spec recommendation) and delete them on first use — whether the verification succeeds or fails.
Related
- Designing Secure Registration Endpoints — parent cluster covering payload routing, challenge issuance, and endpoint hardening
- Implementing Authentication Verification Logic — assertion verification and signCount replay detection after registration
- Best Practices for FIDO2 Challenge Generation — CSPRNG requirements and TTL management for challenges used in both registration and authentication
- Handling WebAuthn Signature Verification in Node.js — deep dive into assertion signature verification using the stored public key
- Attestation vs Assertion Explained — protocol-level distinction between the registration and authentication flows