API reference

withAttestation() reference

A middleware wrapper that handles challenge consumption, attestation verification, and device key persistence.


Signature

function withAttestation(
  options: WithAttestationOptions,
  handler: (
    req: Request,
    context: AttestationContext,
  ) => Response | Promise<Response>,
): (req: Request) => Promise<Response>

Options

FieldTypeRequiredDescription
appIdstringYesYour Team ID + bundle ID (e.g., "TEAMID1234.com.example.app").
developmentEnvbooleanNoDefault false. Set true for development AAGUID.
consumeChallenge(challenge: Uint8Array) => Promise<boolean>YesAtomic single-use consume. Return true if the challenge was valid, unused, and unexpired (and is now consumed). Return false otherwise.
storeDeviceKey(row: { deviceId, publicKeyPem, signCount, receipt }) => Promise<void>YesPersist the verified key. Caller chooses INSERT vs UPSERT — upsert is usually correct.
extractAttestationExtractAttestationFnNoCustom extraction logic. Default reads a JSON body of { keyId, challenge, attestation } with base64-encoded values.
onError(error: AttestationError, req: Request) => Response | Promise<Response>NoCustom error response handler.

Types

AttestationTimings

Library-internal timing spans in milliseconds.

type AttestationTimings = {
  extractMs: number // Parse body + decode base64 fields
  consumeChallengeMs: number // consumeChallenge callback wall-clock duration
  verifyMs: number // Cryptographic attestation verification
  storeDeviceKeyMs: number // storeDeviceKey callback wall-clock duration
}

AttestationContext

Passed to your handler after successful verification and persistence:

type AttestationContext = {
  deviceId: string // Apple-issued keyId from the request
  publicKeyPem: string // PEM-encoded ECDSA P-256 public key
  signCount: number // Always 0 for a fresh attestation
  receipt: Uint8Array // Raw Apple receipt bytes
  timings: AttestationTimings // Library-internal spans
}

ExtractAttestationFn

type ExtractAttestationFn = (req: Request) => Promise<{
  deviceId: string
  challenge: Uint8Array // raw bytes for consumeChallenge DB lookup
  challengeAsSent: string // original string the client SDK hashed
  attestation: Uint8Array
}>

Custom extraction callback. The default reads a JSON body of the shape { keyId: string, challenge: string, attestation: string } where challenge and attestation are base64-encoded.

The challengeAsSent field is the challenge in the exact form the client SDK received it — before any server-side base64 decoding. Client SDKs (Expo's attestKeyAsync, native DCAppAttestService wrappers) hash this string's UTF-8 bytes to produce clientDataHash before passing to Apple. The middleware hashes the same string to produce a matching clientDataHash. The decoded challenge bytes are used separately for the consumeChallenge DB lookup.

This asymmetry does not exist on the assertion side: there, the string passed to generateAssertionAsync IS the raw HTTP body, so both client and server hash identical bytes by definition.


Default error responses

When verification fails and no onError is provided:

Error codeHTTP statusResponse body
INVALID_FORMAT400{ "error": "...", "code": "INVALID_FORMAT" }
CHALLENGE_INVALID401{ "error": "...", "code": "CHALLENGE_INVALID" }
INVALID_CERTIFICATE_CHAIN401{ "error": "...", "code": "INVALID_CERTIFICATE_CHAIN" }
NONCE_MISMATCH401{ "error": "...", "code": "NONCE_MISMATCH" }
RP_ID_MISMATCH401{ "error": "...", "code": "RP_ID_MISMATCH" }
KEY_ID_MISMATCH401{ "error": "...", "code": "KEY_ID_MISMATCH" }
INVALID_COUNTER401{ "error": "...", "code": "INVALID_COUNTER" }
INVALID_AAGUID401{ "error": "...", "code": "INVALID_AAGUID" }
INTERNAL_ERROR500{ "error": "...", "code": "INTERNAL_ERROR" }

Handler behavior

  • Your handler only runs after successful verification and a successful storeDeviceKey write.
  • The middleware automatically hashes the raw challenge with SHA-256 before passing it to verifyAttestation as clientDataHash. This matches the behavior of client SDKs (Expo's attestKeyAsync, native DCAppAttestService wrappers), which hash the challenge before sending to Apple. You do not need to hash the challenge yourself when using this middleware.
  • A consumeChallenge that returns false (not throws) surfaces as CHALLENGE_INVALID — an expected condition (missing, expired, or already-consumed challenge), not a callback failure.
  • Errors thrown by consumeChallenge or storeDeviceKey are wrapped as INTERNAL_ERROR (HTTP 500) with a static, client-safe message. The original error is attached via error.cause for your own logging and never reflected in the HTTP response body — this prevents accidental leakage of database schema details, constraint names, or driver diagnostics through the unauthenticated attestation endpoint.
  • Any unexpected non-AttestationError thrown inside the middleware pipeline (extractor, verifyAttestation, etc.) is similarly wrapped as INTERNAL_ERROR with a generic "Internal error" message and the original attached via cause.
  • Errors thrown by your handler are not caught — they propagate normally.

Import path: @bradford-tech/supabase-integrity-attest or @bradford-tech/supabase-integrity-attest/attestation

For usage examples, see The withAttestation wrapper guide.

Previous
verifyAssertion()