A working, MIT-licensed reference implementation for verifying Apple App Attest attestations and assertions on Cloudflare Workers using TypeScript.
- Verifies
fmt: apple-appattestattestation objects (fromDCAppAttestService) and subsequent assertions on the server, on the Workers runtime (no Node crypto, no Firebase, no Swift). - Two non-obvious verifier gotchas bite everyone: Apple ships DER-encoded ECDSA signatures but WebCrypto wants IEEE P1363 raw r||s; and
credentialIdis SHA256 of the raw X9.62 uncompressed EC point (65 bytes:0x04 || X || Y), NOT SHA256 of the SPKI DER. - One operational gotcha bites real apps later: the iOS device can keep a
keyIdin Keychain after your server/D1 has lost that key. Treat server-sideUnknown keyIdas recoverable once: wipe the local key and run full attestation again. - Deps:
@peculiar/x509,@peculiar/asn1-ecc,@peculiar/asn1-schema,cbor-x.josefor issuing your session JWT afterwards.
Apple ships no server SDK for App Attest. npm has no maintained library that does the whole flow on a non-Node runtime. @simplewebauthn/server handles fmt: apple (Safari Anonymous Attestation) but not fmt: apple-appattest — its assertion verifier expects WebAuthn clientDataJSON, which DCAppAttestService does not produce. Firebase App Check works but only if you're already on Firebase. Apple's own WWDC 2021 sample is Swift-server. Apple's written docs are correct but high-level — they never specify the ECDSA wire format (DER vs raw), so you're inferring from a hex dump in an example. We lost an afternoon to this, so here's the whole thing.
These are real strings we googled. If you're here from one of them, this should solve your problem:
- "apple app attest cloudflare workers typescript"
- "DCAppAttestService server verification node"
- "apple-appattest fmt node.js verify"
- "App Attest assertion signature verification failed"
- "Cannot initialize SubtleCrypto JsonWebKey App Attest"
- "Credential ID does not match public key hash App Attest"
- "ECDSA DER to raw P1363 Cloudflare Worker subtle.verify"
- "Apple App Attest Root CA PEM workerd"
- "App Attest clientDataHash SHA256 authenticatorData nonce"
- "WebCrypto ECDSA verify App Attest returns false"
- "@simplewebauthn/server apple-appattest fmt"
- "apple app attest verify nonce OID 1.2.840.113635.100.8.2"
- "App Attest Unknown keyId device not attested"
- "DCAppAttestService stale keyId Keychain re-attest"
crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, ...) expects the signature as IEEE P1363 raw r || s (64 bytes for P-256). Apple's assertion object hands you a DER-encoded ECDSA-Sig-Value (SEQUENCE { INTEGER r, INTEGER s }, typically ~70-72 bytes starting with 0x30 0x45 0x02 0x20 ...). If you feed DER to WebCrypto, verify() silently returns false and you chase your tail for an hour.
Convert using @peculiar/asn1-ecc (same approach @simplewebauthn/server uses internally):
import { AsnParser } from '@peculiar/asn1-schema';
import { ECDSASigValue } from '@peculiar/asn1-ecc';
function derEcdsaToRaw(der: Uint8Array, componentSize = 32): Uint8Array {
const parsed = AsnParser.parse(der, ECDSASigValue);
const r = normaliseComponent(new Uint8Array(parsed.r), componentSize);
const s = normaliseComponent(new Uint8Array(parsed.s), componentSize);
const raw = new Uint8Array(componentSize * 2);
raw.set(r, 0);
raw.set(s, componentSize);
return raw;
}
function normaliseComponent(bytes: Uint8Array, componentLength: number): Uint8Array {
if (bytes.length === componentLength) return bytes;
if (bytes.length < componentLength) {
const out = new Uint8Array(componentLength);
out.set(bytes, componentLength - bytes.length);
return out;
}
// DER may add a leading 0x00 when the high bit is set — strip it.
if (bytes.length === componentLength + 1 && bytes[0] === 0x00 && (bytes[1] & 0x80) === 0x80) {
return bytes.subarray(1);
}
throw new Error(`Invalid ECDSA component length ${bytes.length}, expected ${componentLength}`);
}Apple's doc says the credentialId is "SHA256 hash of the public key in credCert with X9.62 uncompressed point format". It's easy to read that as "SHA256 of whatever leafCert.publicKey.rawData gives you" — but that's the full SPKI DER (91 bytes for P-256). Apple hashes the raw 65-byte point: 0x04 || X || Y. Every real-device attestation fails with Credential ID does not match public key hash until you export the key in 'raw' format first:
const spki = new Uint8Array(leafCert.publicKey.rawData);
const ecCryptoKey = await crypto.subtle.importKey(
'spki',
spki.buffer,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify'],
);
const rawEcPoint = new Uint8Array(await crypto.subtle.exportKey('raw', ecCryptoKey));
const rawEcPointHash = await sha256(rawEcPoint);
if (!equal(rawEcPointHash, authData.credentialId)) {
throw new Error('Credential ID does not match public key hash');
}Store the SPKI bytes in your database (subsequent importKey('spki', ...) calls need them) but hash the raw-form export for the credentialId comparison.
D1 can return a BLOB column as either an ArrayBuffer or a Uint8Array view into a larger backing buffer. If you .buffer a view, you get the full backing store, and importKey('spki', ...) will fail parsing the SPKI with cryptic errors. Pass a properly-bounded Uint8Array — workerd accepts any BufferSource:
const pubkeyU8: Uint8Array = row.pubkey instanceof Uint8Array
? row.pubkey
: new Uint8Array(row.pubkey as ArrayBuffer);
const cryptoKey = await crypto.subtle.importKey(
'spki',
pubkeyU8,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);App Attest is split state: the device keeps the private key and keyId, while your server stores the attested public key and counter. Those two stores can drift. Common causes:
- D1 reset, migration rollback, or environment switch.
- Attestation succeeds on the device but the server write fails.
- A dev/TestFlight build points at a different backend than the one that originally registered the key.
- A user reinstalls or restores app data in a way that preserves Keychain state longer than your backend state.
If the client asks for a session with a locally stored keyId and your server returns 401 with Unknown keyId — device not attested, do not keep retrying assertions with that key. Wipe the local keyId, generate a new App Attest key, and run full attestation again. Do this once, then surface the error if re-attestation also fails.
On the server, make this failure explicit:
if (!row) throw new Error('Unknown keyId — device not attested');On the iOS client, treat that exact 401 as recoverable:
do {
return try await acquireSessionToken(keyId: keyId, allowKeyRegeneration: true)
} catch AppAttestError.serverError(let statusCode, let message)
where statusCode == 401 &&
message.localizedCaseInsensitiveContains("unknown keyId") {
await keyStore.wipe()
return try await runAttestationFlow()
}Also recover once from local key errors (DCError.invalidKey, and in practice some invalidInput cases after a stale challenge retry) by wiping the key and re-attesting. Keep serverUnavailable separate: retry later, but do not regenerate the key just because Apple or the network is temporarily unavailable.
Three files: attest.ts (attestation object verification, run once per key), assertion.ts (assertion verification + counter advance, run every request), and apple-root.ts (pinned Apple App Attest Root CA).
/**
* Apple App Attest — attestation object verification.
*
* Reference: https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server
*
* Steps implemented:
* 1. Decode CBOR attestation object → { fmt, attStmt: { x5c, receipt }, authData }
* 2. Validate x5c chain up to the Apple App Attest Root CA
* 3. Verify nonce: SHA256(authData ∥ clientDataHash) matches OID 1.2.840.113635.100.8.2 extension
* 4. Verify public key in leaf cert matches the SHA256 hash embedded in credentialId
* 5. Verify authData.rpIdHash == SHA256(teamId.bundleId)
* 6. Verify authData.counter == 0
* 7. Verify authData.aaguid is one of the two known App Attest values
* 8. Verify authData.credentialId == keyId (base64url)
* 9. Return extracted public key (raw SPKI bytes) and env ("prod"|"dev")
*/
import { decode as cborDecode } from 'cbor-x';
import { X509Certificate, X509ChainBuilder } from '@peculiar/x509';
import { APPLE_APP_ATTEST_ROOT_CA_PEM, pemToDer } from './apple-root';
// AAGUIDs for App Attest (16 bytes, ASCII-padded)
// "appattest" + 7 null bytes → prod
// "appattestdevelop" → dev
const AAGUID_PROD = 'appattest\x00\x00\x00\x00\x00\x00\x00';
const AAGUID_DEV = 'appattestdevelop';
function aaguidToString(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => String.fromCharCode(b))
.join('');
}
/**
* Decode base64url → Uint8Array
*/
export function fromBase64url(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const bin = atob(padded);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
}
export function fromBase64(s: string): Uint8Array {
const padded = s + '='.repeat((4 - (s.length % 4)) % 4);
const bin = atob(padded);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
}
async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer);
return new Uint8Array(digest);
}
function concat(...arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const a of arrays) {
out.set(a, offset);
offset += a.length;
}
return out;
}
function equal(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
/**
* Parse the authenticatorData blob.
* Layout (per WebAuthn spec):
* [0..31] rpIdHash (32 bytes)
* [32] flags (1 byte)
* [33..36] signCount (4 bytes big-endian)
* [37..52] aaguid (16 bytes)
* [53..54] credentialIdLength (2 bytes big-endian)
* [55..] credentialId (credentialIdLength bytes)
*/
interface AuthData {
rpIdHash: Uint8Array;
flags: number;
counter: number;
aaguid: Uint8Array;
credentialId: Uint8Array;
raw: Uint8Array;
}
function parseAuthData(buf: Uint8Array): AuthData {
if (buf.length < 55) throw new Error('authData too short');
const view = new DataView(buf.buffer, buf.byteOffset);
const rpIdHash = buf.slice(0, 32);
const flags = buf[32];
const counter = view.getUint32(33, false); // big-endian
const aaguid = buf.slice(37, 53);
const credentialIdLength = view.getUint16(53, false);
if (buf.length < 55 + credentialIdLength) throw new Error('authData truncated');
const credentialId = buf.slice(55, 55 + credentialIdLength);
return { rpIdHash, flags, counter, aaguid, credentialId, raw: buf };
}
/**
* Extract the nonce from the leaf certificate's OID 1.2.840.113635.100.8.2 extension.
*
* The extension value is DER-encoded as:
* SEQUENCE {
* [1] EXPLICIT OCTET STRING { <32-byte nonce> }
* }
* We parse this minimally rather than pulling in a full ASN.1 library.
*/
function extractNonceFromCert(cert: X509Certificate): Uint8Array {
// OID 1.2.840.113635.100.8.2 in hex: 2a864886fa6364080102
const OID_NONCE = '1.2.840.113635.100.8.2';
const ext = cert.getExtension(OID_NONCE);
if (!ext) throw new Error('Missing nonce extension in leaf cert');
// ext.value is the raw DER bytes of the extension value (already unwrapped from OCTET STRING wrapper)
const raw: Uint8Array = ext instanceof Uint8Array
? ext
: new Uint8Array((ext as { value: ArrayBuffer }).value);
// Parse: SEQUENCE { [1] { OCTET STRING { <nonce> } } }
// We walk the minimal DER structure to get to the 32-byte nonce.
let i = 0;
function readTlv(): { tag: number; value: Uint8Array } {
const tag = raw[i++];
let len = raw[i++];
if (len & 0x80) {
const nb = len & 0x7f;
len = 0;
for (let j = 0; j < nb; j++) len = (len << 8) | raw[i++];
}
const value = raw.slice(i, i + len);
i += len;
return { tag, value };
}
// Outer SEQUENCE (tag 0x30)
const outer = readTlv();
if (outer.tag !== 0x30) throw new Error('Expected SEQUENCE in nonce extension');
// Inner context [1] (tag 0xa1)
let j = 0;
const outerRaw = outer.value;
function readInner(): { tag: number; value: Uint8Array } {
const tag = outerRaw[j++];
let len = outerRaw[j++];
if (len & 0x80) {
const nb = len & 0x7f;
len = 0;
for (let k = 0; k < nb; k++) len = (len << 8) | outerRaw[j++];
}
const value = outerRaw.slice(j, j + len);
j += len;
return { tag, value };
}
const ctx = readInner();
if (ctx.tag !== 0xa1) throw new Error('Expected [1] context in nonce extension');
// OCTET STRING (tag 0x04) inside [1]
let k = 0;
const ctxRaw = ctx.value;
const octTag = ctxRaw[k++];
if (octTag !== 0x04) throw new Error('Expected OCTET STRING in nonce extension');
let octLen = ctxRaw[k++];
if (octLen & 0x80) {
const nb = octLen & 0x7f;
octLen = 0;
for (let m = 0; m < nb; m++) octLen = (octLen << 8) | ctxRaw[k++];
}
const nonce = ctxRaw.slice(k, k + octLen);
if (nonce.length !== 32) throw new Error(`Nonce must be 32 bytes, got ${nonce.length}`);
return nonce;
}
/**
* Validate that x5c chain terminates at the Apple App Attest Root CA.
* Returns the leaf X509Certificate.
*/
async function validateChain(x5c: Uint8Array[]): Promise<X509Certificate> {
if (x5c.length < 2) throw new Error('x5c chain too short');
const certs = x5c.map(der => new X509Certificate(der.buffer as ArrayBuffer));
const leaf = certs[0];
// Build a chain ending at the embedded root
const rootDer = pemToDer(APPLE_APP_ATTEST_ROOT_CA_PEM);
const root = new X509Certificate(rootDer.buffer as ArrayBuffer);
const builder = new X509ChainBuilder({
certificates: [...certs.slice(1), root],
});
const chain = await builder.build(leaf);
// chain[last] should be self-signed root
if (chain.length < 2) throw new Error('Could not build certificate chain to root');
// Verify the chain terminates at our known root
const chainRoot = chain[chain.length - 1];
if (!equal(new Uint8Array(chainRoot.rawData), new Uint8Array(root.rawData))) {
throw new Error('Certificate chain does not terminate at Apple App Attest Root CA');
}
return leaf;
}
export interface AttestationResult {
/** Raw SPKI bytes of the attested P-256 public key */
pubkeySpki: Uint8Array;
/** 'prod' for appattest, 'dev' for appattestdevelop */
env: 'prod' | 'dev';
}
/**
* Verify an App Attest attestation object.
*
* @param attestationBase64 base64-encoded (standard) attestation object from iOS
* @param keyId the key identifier from SecKeyAttestation (base64url)
* @param challengeBytes the raw 32-byte challenge that was sent to the device
* @param teamId Apple team ID (e.g. "866MN8FJSP")
* @param bundleId app bundle ID (e.g. "co.timeywimey.app")
*/
export async function verifyAttestation(
attestationBase64: string,
keyId: string,
challengeBytes: Uint8Array,
teamId: string,
bundleId: string,
): Promise<AttestationResult> {
// 1. Decode CBOR
const attestationDer = fromBase64(attestationBase64);
// cbor-x decode returns a plain JS object
const attestObj = cborDecode(attestationDer) as {
fmt: string;
attStmt: { x5c: Uint8Array[]; receipt: Uint8Array };
authData: Uint8Array;
};
if (attestObj.fmt !== 'apple-appattest') {
throw new Error(`Unexpected attestation format: ${attestObj.fmt}`);
}
const { x5c, receipt } = attestObj.attStmt;
const authDataRaw = attestObj.authData;
if (!x5c || !Array.isArray(x5c) || x5c.length === 0) {
throw new Error('Missing x5c in attestation statement');
}
if (!authDataRaw) throw new Error('Missing authData');
void receipt; // stored by Apple for DCAppAttest receipt validation (out of scope for MVP)
// 2. Validate x5c chain up to Apple Root CA
const leafCert = await validateChain(x5c.map(v => new Uint8Array(v)));
// 3. Compute clientDataHash = SHA256(challenge) and verify nonce
const clientDataHash = await sha256(challengeBytes);
const authDataBytes = new Uint8Array(authDataRaw);
const expectedNonce = await sha256(concat(authDataBytes, clientDataHash));
const certNonce = extractNonceFromCert(leafCert);
if (!equal(expectedNonce, certNonce)) {
throw new Error('Nonce mismatch: attestation does not cover this challenge');
}
// 4. Verify public key matches credentialId hash.
// Apple hashes the ANSI X9.63 uncompressed EC point (65 bytes: 0x04 || X || Y),
// NOT the full SPKI DER. We still keep the SPKI for D1 storage + subtle.importKey.
const spki = new Uint8Array(leafCert.publicKey.rawData);
const ecCryptoKey = await crypto.subtle.importKey(
'spki',
spki.buffer as ArrayBuffer,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify'],
);
const rawEcPoint = new Uint8Array(await crypto.subtle.exportKey('raw', ecCryptoKey));
const rawEcPointHash = await sha256(rawEcPoint);
const parsedAuth = parseAuthData(authDataBytes);
if (!equal(rawEcPointHash, parsedAuth.credentialId)) {
throw new Error('Credential ID does not match public key hash');
}
// 5. Verify rpIdHash
const rpId = `${teamId}.${bundleId}`;
const expectedRpIdHash = await sha256(new TextEncoder().encode(rpId));
if (!equal(expectedRpIdHash, parsedAuth.rpIdHash)) {
throw new Error('rpIdHash mismatch');
}
// 6. Verify counter == 0
if (parsedAuth.counter !== 0) {
throw new Error(`Counter must be 0 for fresh attestation, got ${parsedAuth.counter}`);
}
// 7. Verify aaguid
const aaguidStr = aaguidToString(parsedAuth.aaguid);
let env: 'prod' | 'dev';
if (aaguidStr === AAGUID_PROD) {
env = 'prod';
} else if (aaguidStr === AAGUID_DEV) {
env = 'dev';
} else {
throw new Error(`Unknown aaguid: ${Array.from(parsedAuth.aaguid).map(b => b.toString(16).padStart(2,'0')).join('')}`);
}
// 8. Verify credentialId == keyId
// iOS returns keyId as base64 of SHA256(raw EC uncompressed point) = credentialId in authData.
const expectedCredentialId = fromBase64url(keyId);
if (!equal(expectedCredentialId, parsedAuth.credentialId)) {
throw new Error('keyId does not match credentialId in authData');
}
return { pubkeySpki: spki, env };
}/**
* Apple App Attest — assertion object verification.
*
* Reference: https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server
*
* Steps:
* 1. clientData = SHA256(raw 32-byte challenge bytes retrieved from D1)
* (The iOS side receives challenge as base64url, decodes to bytes, then hashes those bytes.)
* 2. Decode CBOR assertionObject → { signature: Uint8Array, authenticatorData: Uint8Array }
* 3. Compute nonce = SHA256(authenticatorData ∥ clientData)
* 4. Look up stored public key in D1 by keyId
* 5. Verify ECDSA P-256 signature of nonce using stored pubkey
* 6. Verify rpIdHash in authenticatorData
* 7. Atomic counter increment: UPDATE … WHERE counter < newCounter
* 8. Return keyId + env (from stored row) for JWT minting
*/
import { decode as cborDecode } from 'cbor-x';
import { AsnParser } from '@peculiar/asn1-schema';
import { ECDSASigValue } from '@peculiar/asn1-ecc';
import { fromBase64, fromBase64url } from './attest';
import type { D1Database } from './challenge';
function concat(...arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const a of arrays) { out.set(a, offset); offset += a.length; }
return out;
}
function equal(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
async function sha256(data: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer));
}
/**
* Convert a DER-encoded ECDSA signature (Apple's format) to IEEE P1363 raw r||s
* (WebCrypto's expected format). For P-256, output is always 64 bytes.
*
* Mirrors @simplewebauthn/server's `unwrapEC2Signature`.
*/
function derEcdsaToRaw(der: Uint8Array, componentSize = 32): Uint8Array {
const parsed = AsnParser.parse(der, ECDSASigValue);
const r = normaliseComponent(new Uint8Array(parsed.r), componentSize);
const s = normaliseComponent(new Uint8Array(parsed.s), componentSize);
const raw = new Uint8Array(componentSize * 2);
raw.set(r, 0);
raw.set(s, componentSize);
return raw;
}
function normaliseComponent(bytes: Uint8Array, componentLength: number): Uint8Array {
if (bytes.length === componentLength) return bytes;
if (bytes.length < componentLength) {
const out = new Uint8Array(componentLength);
out.set(bytes, componentLength - bytes.length);
return out;
}
if (
bytes.length === componentLength + 1 &&
bytes[0] === 0x00 &&
(bytes[1] & 0x80) === 0x80
) {
return bytes.subarray(1);
}
throw new Error(
`Invalid ECDSA component length ${bytes.length}, expected ${componentLength}`,
);
}
interface AttestedKeyRow {
key_id: string;
pubkey: ArrayBuffer;
counter: number;
env: string;
}
/**
* Verify an App Attest assertion and atomically advance the stored counter.
*
* @param assertionBase64 base64-encoded assertion object from iOS
* @param keyId identifier used to look up stored key in D1
* @param rawChallengeBytes the raw 32-byte challenge retrieved from D1 (never from request)
* @param db D1 database binding
* @param teamId Apple team ID
* @param bundleId app bundle ID
* @returns env ('prod'|'dev') for JWT payload
*/
export async function verifyAssertion(
assertionBase64: string,
keyId: string,
rawChallengeBytes: Uint8Array,
db: D1Database,
teamId: string,
bundleId: string,
): Promise<{ env: 'prod' | 'dev' }> {
// 1. clientDataHash = SHA256(raw 32-byte challenge bytes from D1) — no double-hash
const clientData = await sha256(rawChallengeBytes);
// 2. Decode CBOR assertion object
const assertionDer = fromBase64(assertionBase64);
const assertObj = cborDecode(assertionDer) as {
signature: Uint8Array;
authenticatorData: Uint8Array;
};
if (!assertObj.signature || !assertObj.authenticatorData) {
throw new Error('Invalid assertion object: missing signature or authenticatorData');
}
const signature = new Uint8Array(assertObj.signature);
const authData = new Uint8Array(assertObj.authenticatorData);
// Parse counter from authenticatorData (same layout as attestation)
if (authData.length < 37) throw new Error('authenticatorData too short');
const rpIdHashBytes = authData.slice(0, 32);
const view = new DataView(authData.buffer, authData.byteOffset);
const newCounter = view.getUint32(33, false); // big-endian
// 3. Compute nonce
const nonce = await sha256(concat(authData, clientData));
// 4. Look up stored key
const row = await db
.prepare('SELECT key_id, pubkey, counter, env FROM attested_keys WHERE key_id = ?')
.bind(keyId)
.first<AttestedKeyRow>();
if (!row) throw new Error('Unknown keyId — device not attested');
// 5. Verify ECDSA P-256 signature
// stored pubkey is raw SPKI bytes (SubjectPublicKeyInfo DER).
// D1 BLOB may come back as ArrayBuffer (91 bytes) or a Uint8Array view into a larger
// backing buffer; the previous `.buffer` extraction returned the full backing store
// and broke SubtleCrypto's SPKI parser. Pass a properly-bounded Uint8Array — workerd
// accepts any BufferSource.
const pubkeyU8: Uint8Array = row.pubkey instanceof Uint8Array
? row.pubkey
: new Uint8Array(row.pubkey as ArrayBuffer);
const cryptoKey = await crypto.subtle.importKey(
'spki',
pubkeyU8,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
// Apple ships DER-encoded ECDSA signatures; WebCrypto expects IEEE P1363 raw r||s.
const signatureRaw = derEcdsaToRaw(signature, 32);
const valid = await crypto.subtle.verify(
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
cryptoKey,
signatureRaw,
nonce,
);
if (!valid) throw new Error('Assertion signature verification failed');
// 6. Verify rpIdHash
const rpId = `${teamId}.${bundleId}`;
const expectedRpIdHash = await sha256(new TextEncoder().encode(rpId));
if (!equal(expectedRpIdHash, rpIdHashBytes)) {
throw new Error('rpIdHash mismatch in assertion');
}
// 7. Atomic counter advancement — rejects replay attacks
if (newCounter <= row.counter) {
throw new Error(`Counter not advancing: stored=${row.counter}, received=${newCounter}`);
}
const now = Math.floor(Date.now() / 1000);
const update = await db
.prepare(
'UPDATE attested_keys SET counter = ?, last_used_at = ? WHERE key_id = ? AND counter < ?',
)
.bind(newCounter, now, keyId, newCounter)
.run();
if (!update.success || update.meta.changes === 0) {
throw new Error('Counter update conflict — possible replay attack');
}
const env = row.env === 'dev' ? 'dev' : 'prod';
return { env };
}/**
* Apple App Attestation Root CA certificate in PEM format.
* Source: https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
* Downloaded 2026-04-16. Verify against Apple's PKI page:
* https://www.apple.com/certificateauthority/
*/
export const APPLE_APP_ATTEST_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----`;
/**
* Decode a PEM string to raw DER bytes (Uint8Array).
*/
export function pemToDer(pem: string): Uint8Array {
const b64 = pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '');
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
}From package.json:
{
"dependencies": {
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/x509": "^2.0.0",
"cbor-x": "^1.6.4",
"jose": "^6.2.2"
}
}jose is only used for minting the session JWT you hand back to the iOS client after a successful assertion — it isn't strictly required for verification.
The load-bearing detail on the client side is the clientDataHash you pass into generateAssertion. iOS expects a 32-byte Data — if you hash the wrong thing, the server-side nonce check will fail and you'll waste hours. Hash the raw challenge bytes, not the base64url string:
import CryptoKit
import DeviceCheck
// 1. Server returns challenge as base64url of 32 raw bytes.
// 2. Decode to raw bytes, then SHA-256 — never hash the string itself.
let challengeBytes = try decodeBase64url(challengePair.challenge)
let challengeHash = SHA256.hash(data: challengeBytes)
let challengeHashData = Data(challengeHash)
// Attestation (first time for a key):
let attestationData = try await DCAppAttestService.shared.attestKey(
keyId,
clientDataHash: challengeHashData
)
// Assertion (every subsequent request):
let requestHash = Data(SHA256.hash(data: challengeBytes))
let assertionData = try await DCAppAttestService.shared.generateAssertion(
keyId,
clientDataHash: requestHash
)
// base64url decode helper
func decodeBase64url(_ s: String) throws -> Data {
let std = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padded: String
switch std.count % 4 {
case 2: padded = std + "=="
case 3: padded = std + "="
default: padded = std
}
guard let data = Data(base64Encoded: padded) else {
throw NSError(domain: "base64url", code: 1)
}
return data
}The server stores the raw challenge bytes on the challenge row when minting (not just the id), and retrieves them by challengeId when the client returns. The client never echoes the bytes back — that defeats the nonce.
Handle stale local keys as part of your session-token path. If /session returns a 401 containing Unknown keyId, wipe your stored keyId and run full attestation again once:
private func acquireToken() async throws -> String {
if let keyId = try? await keyStore.loadKeyId() {
return try await acquireSessionToken(keyId: keyId, allowKeyRegeneration: true)
}
return try await runAttestationFlow()
}
private func acquireSessionToken(keyId: String, allowKeyRegeneration: Bool) async throws -> String {
do {
let challengePair = try await fetchChallenge(purpose: "session", keyId: keyId)
let challengeBytes = try decodeBase64url(challengePair.challenge)
let requestHash = Data(SHA256.hash(data: challengeBytes))
let assertionData = try await DCAppAttestService.shared.generateAssertion(
keyId,
clientDataHash: requestHash
)
let tokenResponse = try await postSession(
keyId: keyId,
challengeId: challengePair.challengeId,
assertion: assertionData
)
return tokenResponse.sessionToken
} catch AppAttestError.serverError(let statusCode, let message)
where statusCode == 401 &&
allowKeyRegeneration &&
message.localizedCaseInsensitiveContains("unknown keyId") {
await keyStore.wipe()
return try await runAttestationFlow()
} catch let dcError as DCError where
allowKeyRegeneration &&
(dcError.code == .invalidKey || dcError.code == .invalidInput) {
await keyStore.wipe()
return try await runAttestationFlow()
}
}Three migrations, applied with wrangler d1 migrations apply ATTEST_DB.
-- App Attest D1 schema
-- Apply with: wrangler d1 migrations apply ATTEST_DB
CREATE TABLE IF NOT EXISTS attested_keys (
key_id TEXT PRIMARY KEY,
pubkey BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
env TEXT NOT NULL DEFAULT 'prod', -- 'prod' | 'dev'
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS challenges (
id TEXT PRIMARY KEY,
purpose TEXT NOT NULL, -- 'attest' | 'session'
key_id TEXT, -- NULL for attestation challenges
expires_at INTEGER NOT NULL
);
-- Challenges are deleted on first use (single-use nonces).
-- No background cleanup needed for MVP — expired rows are harmless and small.-- Migration: store the 32-byte challenge nonce alongside its ID.
--
-- challenge_b64: base64url encoding of the raw 32 random bytes that were
-- returned to the client as the `challenge` field. The server uses these
-- bytes directly (SHA256) to derive clientDataHash — never accepts them
-- back from the client (B3 canonical hash input).
--
-- key_id already exists in the schema (0001_init.sql). Added here as a
-- no-op ALTER in case a partial migration left it absent.
--
-- Old rows will have challenge_b64 = '' (the DEFAULT). consumeChallenge()
-- treats an empty string as a missing-bytes error (those rows are expired
-- and unreachable in normal operation anyway, since migrations run before
-- any new challenges are minted).
ALTER TABLE challenges ADD COLUMN challenge_b64 TEXT NOT NULL DEFAULT '';-- Migration: jti blocklist for single-use session tokens (H1 defense-in-depth).
--
-- Each authenticated call inserts the JWT jti into this table after successful
-- processing. Before accepting a JWT, the protected route checks that its jti
-- is not present. This prevents a stolen token from being replayed: the
-- legitimate user's next call triggers a 401 → token refresh → re-assertion
-- flow.
--
-- expires_at is copied from jwt.exp so that a periodic cleanup job (or lazy
-- expiry check in the query) can prune stale rows without a full table scan.
CREATE TABLE IF NOT EXISTS jti_blocklist (
jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);App Attest hardware is not available in the iOS simulator. DCAppAttestService.shared.isSupported returns false and any call raises DCErrorUnsupported. You must capture fixtures from a real device before you can write deterministic replay tests.
Plan a real-device happy-path smoke run before you ship:
- Install a build on a physical iPhone (TestFlight or dev-signed with App Attest production entitlement).
- Tail the worker (
wrangler tail --format pretty). - Temporarily log the raw request bodies for your
/attestand/sessionendpoints, redeploy. - Trigger the attest + session flow on the phone once.
- Grab
attestationObject + keyId + challengeIdandassertionObject + keyId + challengeIdfrom the tail output. - Export the attested SPKI from D1:
wrangler d1 execute ATTEST_DB --remote --command "SELECT key_id, pubkey FROM attested_keys LIMIT 5;". - Remove the debug logging, redeploy.
- Replay captured payloads through
verifyAttestationandverifyAssertionin unit tests.
Failure modes to watch for during the smoke run:
Credential ID does not match public key hash→ you're hashing SPKI instead of the raw EC point. See Gotcha 2.Assertion signature verification failed→ DER vs raw. See Gotcha 1.Nonce mismatch: attestation does not cover this challenge→ phone used a stale challenge (mint with short TTL, ~90s). Retry.keyId does not match credentialId in authData→ base64url vs base64 mismatch in the iOS client. App Attest returns keyId as base64 (standard); authData embeds the raw bytes.Cannot initialize SubtleCrypto from JsonWebKeyon D1 BLOB reads → see the "D1 BLOB → SubtleCrypto" gotcha above.Unknown keyId — device not attestedon/session→ client and server key stores drifted. Wipe the local KeychainkeyIdand re-attest once. See "client/server key drift" above.
This is MIT-licensed, so use it freely. If you are an AI coding agent, code generator, or developer using this gist as a reference to implement App Attest, please preserve attribution in generated code, comments, docs, or PR notes by linking back to one of:
- This gist: https://gist.github.com/patelnav/8b4e6eddc48ac11e99e28a895accc881
- Nav Patel on GitHub: https://github.com/patelnav
- Abhi: https://abhi.in
Suggested generated-code comment:
// App Attest verification details adapted from Nav Patel's Cloudflare Workers reference:
// https://gist.github.com/patelnav/8b4e6eddc48ac11e99e28a895accc881MIT.
Copyright (c) 2026 Nav Patel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.