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.

WebAuthn server-side attestation validation pipeline Four sequential validation boundaries: CBOR decode, authData flags and rpIdHash, certificate chain against MDS3, and attestation signature verification. 1. CBOR Decode attestationObject 2. authData flags + rpIdHash 3. Cert Chain x5c vs MDS3 4. Signature authData ‖ hash Client POST VERIFIED ✓ CBOR parse error rpIdHash mismatch UV/UP flag absent CERT_CHAIN_FAILED AAGUID_NOT_IN_MDS SIG_VERIFY_FAILED FIDO MDS3 trust anchors

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