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

  1. Receive the payload — The client sends attestation (base64), keyId (base64, used as device_id), and the challenge identifier.

  2. Atomically consume the challengeDELETE ... RETURNING on app_attest_challenges filtered by purpose = 'attestation' and expires_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.

  3. Hash the challenge — Compute clientDataHash = SHA-256(challenge). Client SDKs (Expo's attestKeyAsync, native DCAppAttestService wrappers) hash the challenge before passing to Apple, so the server must hash it too for the nonce to match. If you use the withAttestation wrapper instead of calling verifyAttestation directly, this step is handled for you automatically.

  4. Call verifyAttestation() — Pass the appInfo, keyId, clientDataHash (the SHA-256 hash from the previous step, NOT the raw challenge), and the attestation.

  5. Store the result — Upsert into app_attest_devices: publicKeyPem, signCount (always 0), and receipt. Upsert (not insert) because re-attesting an existing device_id is cryptographically safe — Apple has re-signed the key.

  6. Handle errorsverifyAttestation() throws AttestationError with a typed code. 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.

Previous
Supabase Edge Functions