Attestation vs Assertion Explained

In the WebAuthn ecosystem, attestation and assertion represent two non-interchangeable cryptographic phases governing the credential lifecycle. Attestation — produced during registration — establishes authenticator trust: it proves the device is genuine and manufactured by a known entity. Assertion — produced during authentication — establishes user identity: it proves that the holder of the private key has consented to the current transaction. Conflating them creates broken threat models, compliance gaps, and fragile authentication pipelines. For the broader protocol context these two operations live within, see WebAuthn & FIDO2 Protocol Fundamentals.


Concept Definition and Spec Grounding

Attestation (W3C WebAuthn §6.5)

Attestation is the authenticator’s signed statement that a new credential was created inside a specific hardware security boundary. The relying party receives an attestationObject — a CBOR-encoded structure containing three fields:

Field Type Content
fmt text Attestation statement format identifier (packed, tpm, android-key, fido-u2f, apple, none)
authData bytes 37+ bytes of authenticator data (see byte layout below)
attStmt map Format-specific statement: certificate chain (x5c), ECDSA signature (sig), algorithm ID (alg)

The authData structure for attestation:

Bytes 0–31:   rpIdHash       (SHA-256 of the RP ID)
Byte  32:     flags          (UP=bit0, UV=bit2, AT=bit6, ED=bit7)
Bytes 33–36:  signCount      (big-endian uint32, 0 for platform authenticators that don't maintain counters)
Bytes 37–53:  AAGUID         (16-byte authenticator model identifier)
Bytes 54–55:  credIdLen      (big-endian uint16)
Bytes 56–(56+credIdLen-1): credentialId
Remaining:    credentialPublicKey (CBOR-encoded COSE key)

The AT flag (bit 6) in byte 32 signals that attested credential data is present — the AAGUID, credIdLen, credentialId, and credentialPublicKey fields follow only when AT=1.

Assertion (W3C WebAuthn §6.3)

Assertion is the authenticator’s signature over a fresh server challenge combined with session-specific context. The relying party receives:

Field Type Content
authenticatorData bytes Flags + signCount only (no AAGUID or credential public key)
clientDataJSON bytes UTF-8 JSON: type, challenge, origin, crossOrigin
signature bytes ECDSA/EdDSA signature over authData || SHA-256(clientDataJSON)
userHandle bytes? The user.id set at registration (may be absent for non-discoverable credentials)

The authenticatorData in assertion is shorter than in attestation — it omits the AT bit, AAGUID, and credential public key because those were already bound at registration.


Architecture and Data Flow

The diagram below shows how attestation and assertion fit into the full WebAuthn lifecycle, and where each validation step executes.

WebAuthn Attestation vs Assertion Data Flow Sequence diagram comparing the registration (attestation) flow on the left with the authentication (assertion) flow on the right, showing Browser, Authenticator, and Relying Party actors. REGISTRATION — Attestation AUTHENTICATION — Assertion Browser Authenticator Relying Party Browser Authenticator Relying Party PublicKeyCredentialCreationOptions (challenge, rp, user, pubKeyCredParams) navigator.credentials.create() User presence + keypair generated attestationObject (CBOR) POST /register (credential response) RP validates: ① fmt + attStmt ② AAGUID → MDS3 ③ rpIdHash ④ Store public key Credential stored ✓ PublicKeyCredentialRequestOptions (challenge, rpId, allowCredentials) navigator.credentials.get() User verification + assertion signed authData + signature POST /authenticate (assertion) RP validates: ① clientDataJSON ② flags (UP, UV) ③ signCount ④ Verify signature Session issued ✓ Proves: authenticator identity Certificate chain → AAGUID → MDS3 Payload: attestationObject (CBOR) One-time: only at registration Proves: user identity & consent Challenge → signature → stored public key Payload: authData + signature Every login: per-session fresh challenge

Implementation Guide

Step 1 — Configure attestation conveyance at registration

Choose a conveyance policy before calling navigator.credentials.create(). This is the only place to assert hardware-trust requirements. (W3C WebAuthn §5.4.6)

// server: build PublicKeyCredentialCreationOptions
function buildRegistrationOptions(
  userId: string,
  userName: string,
  riskTier: 'consumer' | 'enterprise' | 'regulated'
): PublicKeyCredentialCreationOptions {
  const attestationPolicy: AttestationConveyancePreference =
    riskTier === 'regulated'   ? 'direct'    :
    riskTier === 'enterprise'  ? 'indirect'  :
                                 'none';

  return {
    challenge: crypto.randomBytes(32),           // §13.4.3: 16+ bytes CSPRNG
    rp: { id: 'example.com', name: 'Example' },
    user: {
      id: Buffer.from(userId, 'utf-8'),
      name: userName,
      displayName: userName,
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7  },  // ES256 (COSE alg -7)
      { type: 'public-key', alg: -257 }, // RS256 (COSE alg -257) for Windows Hello
    ],
    authenticatorSelection: {
      userVerification: 'required',
      residentKey: 'required',        // discoverable credential
    },
    attestation: attestationPolicy,
    timeout: 120_000,
  };
}

Step 2 — Validate the attestationObject on the server

Parse and verify the CBOR-encoded attestationObject returned by navigator.credentials.create(). (W3C WebAuthn §7.1)

import { decodeAttestationObject, parseAuthenticatorData } from '@simplewebauthn/server/helpers';
import { MetadataService } from '@simplewebauthn/server';

async function verifyAttestation(
  attestationObjectBase64: string,
  clientDataJSONBase64: string,
  expectedOrigin: string,
  expectedRpId: string,
): Promise<{ credentialId: Buffer; publicKey: Buffer; aaguid: string }> {
  const attestationObject = decodeAttestationObject(
    Buffer.from(attestationObjectBase64, 'base64url')
  );

  const { fmt, authData, attStmt } = attestationObject;
  const parsedAuthData = parseAuthenticatorData(authData);

  // §7.1 step 7: verify rpIdHash
  const expectedRpIdHash = crypto.createHash('sha256').update(expectedRpId).digest();
  if (!parsedAuthData.rpIdHash.equals(expectedRpIdHash)) {
    throw new Error('rpIdHash mismatch — RP ID does not match');
  }

  // §7.1 step 12: AT flag must be set for registration
  if (!parsedAuthData.flags.at) {
    throw new Error('AT flag not set — no attested credential data present');
  }

  // §7.1 step 15: verify attestation statement
  const aaguid = parsedAuthData.aaguid!.toString('hex');

  if (fmt !== 'none') {
    // Cross-reference AAGUID against FIDO Alliance MDS3
    const metadata = await MetadataService.getStatement(aaguid);
    if (!metadata) throw new Error(`AAGUID ${aaguid} not found in MDS3`);
    if (metadata.statusReports.some(r => r.status === 'REVOKED')) {
      throw new Error(`Authenticator model ${aaguid} has been revoked in MDS3`);
    }
  }

  return {
    credentialId: parsedAuthData.credentialID!,
    publicKey: parsedAuthData.credentialPublicKey!,
    aaguid,
  };
}

Step 3 — Build the assertion request at login

Issue a per-session challenge with a short TTL. (W3C WebAuthn §7.2 step 2)

// server: build PublicKeyCredentialRequestOptions
async function buildAuthenticationOptions(
  knownCredentialIds: Buffer[]
): Promise<PublicKeyCredentialRequestOptions> {
  const challenge = crypto.randomBytes(32);

  // Store challenge in session with 5-minute TTL
  await sessionStore.set(`challenge:${challenge.toString('hex')}`, {
    expiresAt: Date.now() + 5 * 60 * 1000,
  });

  return {
    challenge,
    rpId: 'example.com',
    allowCredentials: knownCredentialIds.map(id => ({
      type: 'public-key',
      id,
      transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble'],
    })),
    userVerification: 'required',
    timeout: 120_000,
  };
}

Step 4 — Verify the assertion signature

Validate clientDataJSON, the authenticatorData flags, signCount, and the cryptographic signature. (W3C WebAuthn §7.2 steps 11–21)

import { verifyAuthenticationResponse } from '@simplewebauthn/server';

async function verifyAssertion(
  assertionResponse: AuthenticationResponseJSON,
  expectedChallenge: string,
  storedCredential: { publicKey: Buffer; signCount: number },
  expectedRpId: string,
  expectedOrigin: string,
): Promise<{ newSignCount: number }> {
  const verification = await verifyAuthenticationResponse({
    response: assertionResponse,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: expectedRpId,
    credential: {
      id: assertionResponse.id,
      publicKey: storedCredential.publicKey,
      counter: storedCredential.signCount,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) {
    throw new Error('Assertion signature verification failed');
  }

  const { authenticationInfo } = verification;

  // §7.2 step 17: signCount enforcement
  // Platform authenticators (synced passkeys) may return 0 — this is spec-compliant.
  // A non-zero counter that decrements indicates credential cloning.
  if (
    authenticationInfo.newCounter !== 0 &&
    authenticationInfo.newCounter <= storedCredential.signCount
  ) {
    throw new Error(
      `signCount regression: stored=${storedCredential.signCount}, received=${authenticationInfo.newCounter}. Possible credential clone.`
    );
  }

  return { newSignCount: authenticationInfo.newCounter };
}

Step 5 — Persist the credential and issue a session

async function completeRegistration(
  userId: string,
  credentialId: Buffer,
  publicKey: Buffer,
  aaguid: string,
  signCount: number,
): Promise<void> {
  await db.query(
    `INSERT INTO webauthn_credentials
       (user_id, credential_id, public_key_cose, aaguid, sign_count, created_at)
     VALUES ($1, $2, $3, $4, $5, NOW())`,
    [userId, credentialId, publicKey, aaguid, signCount]
  );
}

async function completeAuthentication(
  credentialId: Buffer,
  newSignCount: number,
): Promise<void> {
  await db.query(
    `UPDATE webauthn_credentials SET sign_count = $1, last_used_at = NOW()
     WHERE credential_id = $2`,
    [newSignCount, credentialId]
  );
}

Validation Checklist

Use this checklist for every registration and every authentication event:

Registration (Attestation)

Authentication (Assertion)


Error Reference Table

Error / Condition HTTP Status Trigger Diagnostic
NotAllowedError 400 Timeout, user dismissed, or no matching credential Log allowCredentials IDs vs stored; check 120 s timeout
SecurityError 400 Origin mismatch or non-HTTPS context Verify window.location.origin against RP origin in server options
InvalidStateError 409 credentialId already registered for this user Query DB for duplicate before calling create()
rpIdHash mismatch 422 rp.id in options doesn’t match server config Confirm rp.id is an effective domain suffix of the origin
Challenge expired / replayed 403 TTL exceeded or challenge used twice Enforce session-store TTL ≤ 5 min; delete on first use
signCount regression 403 Non-zero counter decreased — possible credential clone Log credential ID, flag account; prompt step-up or re-register
AAGUID not in MDS3 422 Unknown authenticator model Fall back to none-attestation policy, or reject for regulated flows
Attestation cert revoked 403 Compromised hardware batch flagged by FIDO Alliance Poll MDS3 refresh weekly; cache results with nextUpdate TTL
UV flag unset with required 403 Authenticator did not perform user verification Enforce requireUserVerification: true on server, return 403

Platform and Library Notes

@simplewebauthn/server (Node.js/TypeScript)

verifyRegistrationResponse() and verifyAuthenticationResponse() handle CBOR parsing, signature verification, and signCount comparison. Pass requireUserVerification: true explicitly — the default in some versions does not enforce UV. The MetadataService integration provides automated MDS3 queries; initialise it at server startup with MetadataService.initialize().

fido2-lib (Node.js)

Exposes raw CBOR access via lib.createCredential() and lib.assertionResult(). You must supply the AAGUID metadata lookup and signCount enforcement manually — the library does not bundle MDS3 by default.

py_webauthn (Python)

verify_registration_response() and verify_authentication_response() accept the response dict. Set require_user_verification=True. Python’s cbor2 library handles the CBOR decoding; ensure it is pinned to a version without known deserialization bugs.

WebAuthn4J (Java/Spring)

Full MDS3 support via MetadataService and CertPathTrustworthinessVerifier. Handles tpm and android-key attestation formats, which are common in enterprise deployments on Windows Hello and Android Keystore respectively.

iOS Safari (platform authenticator)

Returns fmt: 'apple' with x5c containing the device attestation certificate chain. The Apple attestation statement uses a custom nonce extension rather than a direct signature over authData. The UV flag is always set when Face ID or Touch ID is used. signCount is always 0 — no monotonic counter is maintained for iCloud Keychain-synced credentials.

Android Chrome (platform authenticator)

Returns fmt: 'android-key' when Play Integrity is available, or fmt: 'packed' on older Android. The BE flag (bit 3) and BS flag (bit 4) in authData byte 32 reflect whether the credential is backed up to Google Password Manager.

Windows Hello (platform authenticator)

Returns fmt: 'tpm' with AIK certificates. The challenge-response authentication flow page documents the specific CTAP2 transport details. UV is set when Windows Hello PIN or biometric is used. signCount increments monotonically — treat decrement as a clone signal.


Pitfalls and Security Hardening

1. Accepting none attestation in regulated environments Root cause: defaulting to attestation: 'none' to maximise authenticator compatibility, without a tiered policy. Mitigation: implement dynamic attestation policy routing based on user risk tier — require direct for financial, healthcare, and government flows; allow none only for low-risk consumer tiers.

2. Weak challenge entropy Root cause: using predictable values (timestamps, sequential integers) or PRNG instead of CSPRNG for challenge generation. Mitigation: use crypto.randomBytes(32) (Node.js) or crypto.getRandomValues(new Uint8Array(32)) (browser). Never reuse challenges. For details, see best practices for FIDO2 challenge generation.

3. Ignoring signCount on synced passkeys Root cause: rejecting assertions because the synced passkey returns signCount === 0, or treating a missing increment as a clone signal on platform authenticators. Mitigation: treat signCount === 0 as “counter not supported” (spec-compliant for iCloud/Google Password Manager); only flag a regression when a previously non-zero counter decreases.

4. Conflating UV with user authentication Root cause: assuming that because UP (user present) is set, the user was verified. An authenticator satisfies UP with a simple touch; UV requires PIN or biometric. Mitigation: enforce userVerification: 'required' in PublicKeyCredentialRequestOptions and verify the UV flag is set server-side on every assertion.

5. Stale AAGUID metadata cache Root cause: querying FIDO MDS3 at deploy time and caching indefinitely, missing new revocations. Mitigation: refresh MDS3 metadata on the nextUpdate date in the BLOB; re-check stored AAGUIDs for revocation during scheduled maintenance windows.

6. Origin comparison using prefix or substring matching Root cause: checking that clientDataJSON.origin contains the expected domain rather than performing exact equality. Mitigation: compare clientDataJSON.origin byte-for-byte (===) against the pre-configured RP origin including scheme and port. See validating attestation statements on the server for a production checklist.


Related