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.
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
- WebAuthn & FIDO2 Protocol Fundamentals — parent section covering the full protocol stack
- The Challenge-Response Authentication Flow — CTAP2 transport and session lifecycle in detail
- Public Key vs Symmetric Credential Types — COSE key formats, ES256/RS256 trade-offs, and key storage
- Comparing WebAuthn to Traditional OAuth Flows — why passkeys eliminate phishing vectors inherent in bearer tokens
- Validating Attestation Statements on the Server — production server-side attestation validation pipeline
- Best Practices for FIDO2 Challenge Generation — CSPRNG requirements, TTL enforcement, and replay prevention