Guides
Verifying attestations
A complete edge function that receives an attestation from your app and verifies it, built directly on verifyAttestation() — for when you need more control than the withAttestation wrapper provides.
The edge function
import { createClient } from 'jsr:@supabase/supabase-js@2'
import {
verifyAttestation,
AttestationError,
AttestationErrorCode,
} from '@bradford-tech/supabase-integrity-attest'
const appInfo = {
appId: Deno.env.get('APP_ID')!,
developmentEnv: Deno.env.get('ENVIRONMENT') !== 'production',
}
// supabase-js serializes Uint8Array via JSON.stringify, which writes
// {"0":byte,"1":byte,...} JSON text — not raw bytes — into bytea
// columns. Convert to Postgres hex literal so inserts and filters
// round-trip correctly.
function toPgBytea(bytes: Uint8Array): string {
let hex = '\\x'
for (const b of bytes) hex += b.toString(16).padStart(2, '0')
return hex
}
Deno.serve(async (req: Request) => {
const { attestation, keyId, challenge } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
// 1. Atomically consume the challenge — single-use semantics via
// DELETE ... RETURNING so a second attempt fails cleanly.
const { data: consumed } = await supabase
.from('app_attest_challenges')
.delete()
.eq('challenge', challenge)
.eq('purpose', 'attestation')
.gt('expires_at', new Date().toISOString())
.select()
.single()
if (!consumed) {
return new Response(
JSON.stringify({ error: 'Invalid or expired challenge' }),
{ status: 400 },
)
}
// 2. Hash the challenge to produce clientDataHash. Client SDKs
// (Expo's attestKeyAsync, native DCAppAttestService wrappers) hash
// the challenge with SHA-256 before passing to Apple, so the server
// must do the same for the nonce computation to match.
const clientDataHash = new Uint8Array(
await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(consumed.challenge),
),
)
// 3. Verify the attestation
try {
const result = await verifyAttestation(
appInfo,
keyId,
clientDataHash, // SHA-256(challenge) — NOT the raw challenge
attestation, // Base64-encoded CBOR attestation object
)
// 3. Store the device's public key and receipt (upsert — re-attesting
// is cryptographically safe, Apple has re-signed the key).
await supabase.from('app_attest_devices').upsert({
device_id: keyId,
public_key_pem: result.publicKeyPem,
sign_count: result.signCount,
receipt: toPgBytea(result.receipt),
})
return new Response(JSON.stringify({ success: true }), { status: 200 })
} catch (error) {
if (error instanceof AttestationError) {
return new Response(
JSON.stringify({ error: error.message, code: error.code }),
{ status: 401 },
)
}
throw error
}
})
Step by step
Receive the payload — The client sends
attestation(base64),keyId(base64, used asdevice_id), and thechallengeidentifier.Atomically consume the challenge —
DELETE ... RETURNINGonapp_attest_challengesfiltered bypurpose = 'attestation'andexpires_at > now(). If the delete affects zero rows, the challenge was missing, expired, or already consumed — reject the request. This is single-use by construction.Hash the challenge — Compute
clientDataHash = SHA-256(challenge). Client SDKs (Expo'sattestKeyAsync, nativeDCAppAttestServicewrappers) hash the challenge before passing to Apple, so the server must hash it too for the nonce to match. If you use thewithAttestationwrapper instead of callingverifyAttestationdirectly, this step is handled for you automatically.Call
verifyAttestation()— Pass theappInfo,keyId,clientDataHash(the SHA-256 hash from the previous step, NOT the raw challenge), and the attestation.Store the result — Upsert into
app_attest_devices:publicKeyPem,signCount(always 0), andreceipt. Upsert (not insert) because re-attesting an existingdevice_idis cryptographically safe — Apple has re-signed the key.Handle errors —
verifyAttestation()throwsAttestationErrorwith a typedcode. See Types & error codes for the full list.
Challenge management is critical
Never accept a challenge that wasn't generated by your server. The atomic DELETE ... RETURNING pattern above guarantees single-use semantics. Skipping challenge validation lets an attacker replay a previously captured attestation.
Prefer the withAttestation wrapper
For most projects, the withAttestation middleware eliminates this boilerplate and handles all the error-path branching for you. Use the raw verifyAttestation() function only when you need per-request logic the wrapper can't express — custom extraction, non-standard storage, or stepping through the verification pipeline by hand.