System Integration

Verifying Apple JWS Receipts Without a Third-Party Library

StoreKit 2 returns a JWS (JSON Web Signature) for every transaction. The JWS is signed by Apple using ECDSA P-256, with a certificate chain in the x5c header. TermOnMac verifies these JWS receipts on the server using only the Web Crypto API and a hand-rolled DER parser.

JWS Structure

A JWS has three base64url parts joined with dots: header.payload.signature. For StoreKit 2:

  • Header: { "alg": "ES256", "x5c": [leaf_cert, intermediate_cert, root_cert] }
  • Payload: transaction data (productId, expiresDate, etc.)
  • Signature: ECDSA P-256 over header.payload
// relay_server/src/subscription.ts
export interface JWSPayload {
  productId: string;
  expiresDate?: number;
  originalTransactionId?: string;
  transactionId?: string;
  purchaseDate?: number;
  autoRenewProductId?: string;
  autoRenewStatus?: number;
}

interface JWSHeader {
  alg: string;
  x5c?: string[];
  [key: string]: unknown;
}

Decoding the Parts

Base64url decoding is base64 with two character substitutions and removed padding:

function base64urlDecode(input: string): Uint8Array {
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
  const binary = atob(padded);
  return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

export function decodeJWSParts(jws: string): { header: JWSHeader; payload: JWSPayload } | null {
  const parts = jws.split(".");
  if (parts.length !== 3) return null;
  try {
    const headerJson = new TextDecoder().decode(base64urlDecode(parts[0]));
    const payloadJson = new TextDecoder().decode(base64urlDecode(parts[1]));
    return { header: JSON.parse(headerJson), payload: JSON.parse(payloadJson) };
  } catch {
    return null;
  }
}

Extracting the Public Key from x5c

Apple’s JWS uses an x5c certificate chain instead of a JWK. The leaf certificate (first in x5c) contains the public key needed to verify the signature. The Web Crypto API can import a public key from SPKI (SubjectPublicKeyInfo) format, but we have to extract SPKI from the X.509 certificate ourselves.

The DER parsing is hand-rolled — no third-party library:

/** Parse DER tag + length at the given offset. Returns content offset and length. */
function parseDERTagLength(
  bytes: Uint8Array,
  offset: number,
): { tag: number; length: number; headerLength: number } {
  const tag = bytes[offset];
  let length: number;
  let headerLength: number;
  if (bytes[offset + 1] < 0x80) {
    length = bytes[offset + 1];
    headerLength = 2;
  } else {
    const numLengthBytes = bytes[offset + 1] & 0x7f;
    length = 0;
    for (let i = 0; i < numLengthBytes; i++) {
      length = (length << 8) | bytes[offset + 2 + i];
    }
    headerLength = 2 + numLengthBytes;
  }
  return { tag, length, headerLength };
}

DER lengths are either single-byte (< 0x80) or multi-byte (high bit set, low 7 bits indicate the number of length bytes). This handles both forms.

Walking the Certificate Structure

X.509 certificates have a fixed field order in their tbsCertificate (to-be-signed) section. We walk through each field to reach subjectPublicKeyInfo:

/**
 * Extract SubjectPublicKeyInfo (SPKI) from a DER-encoded X.509 certificate.
 *
 * X.509 tbsCertificate field order:
 *   [0] version, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo
 * We walk through each field to reach subjectPublicKeyInfo.
 */
function extractSPKIFromCert(certDER: Uint8Array): Uint8Array {
  // Outer SEQUENCE (Certificate)
  const cert = parseDERTagLength(certDER, 0);
  let offset = cert.headerLength;

  // tbsCertificate SEQUENCE
  const tbs = parseDERTagLength(certDER, offset);
  offset += tbs.headerLength;

  // version [0] EXPLICIT — context tag 0xA0 (skip if present)
  if (certDER[offset] === 0xa0) {
    offset = skipDERElement(certDER, offset);
  }
  // serialNumber INTEGER
  offset = skipDERElement(certDER, offset);
  // signature AlgorithmIdentifier SEQUENCE
  offset = skipDERElement(certDER, offset);
  // issuer Name SEQUENCE
  offset = skipDERElement(certDER, offset);
  // validity SEQUENCE
  offset = skipDERElement(certDER, offset);
  // subject Name SEQUENCE
  offset = skipDERElement(certDER, offset);

  // subjectPublicKeyInfo SEQUENCE — this is what we need
  const spki = parseDERTagLength(certDER, offset);
  return certDER.slice(offset, offset + spki.headerLength + spki.length);
}

Six fields to skip, then the seventh is the SPKI we want. The version field is optional and uses an EXPLICIT [0] context tag (0xA0); if present, we skip it.

Verifying with Web Crypto

Once we have the SPKI bytes, the Web Crypto API takes over:

async function verifyJWSSignature(jws: string): Promise<JWSPayload | null> {
  const parts = jws.split(".");
  if (parts.length !== 3) return null;

  const decoded = decodeJWSParts(jws);
  if (!decoded) return null;

  const { header, payload } = decoded;
  if (header.alg !== "ES256") return null;
  if (!header.x5c || header.x5c.length === 0) return null;

  try {
    // Decode the leaf certificate (first in x5c) and extract its public key
    const leafCertDER = base64Decode(header.x5c[0]);
    const spki = extractSPKIFromCert(leafCertDER);

    const publicKey = await crypto.subtle.importKey(
      "spki",
      spki,
      { name: "ECDSA", namedCurve: "P-256" },
      false,
      ["verify"],
    );

    // Verify the ECDSA signature
    const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
    const signature = base64urlDecode(parts[2]);

    const valid = await crypto.subtle.verify(
      { name: "ECDSA", hash: "SHA-256" },
      publicKey,
      signature,
      signingInput,
    );

    return valid ? payload : null;
  } catch (err) {
    console.error("[subscription] x5c JWS verification error:", err);
    return null;
  }
}

The signing input is the JWS in canonical form: header_b64.payload_b64. The signature in part 3 is the ECDSA signature over this byte sequence.

If crypto.subtle.verify returns true, the payload is trusted. The tier field is then derived from the productId:

export function tierFromProductId(productId: string): UserTier {
  if (productId.includes("premium")) return "premium";
  if (productId.includes("pro")) return "pro";
  return "free";
}

Why Hand-Roll DER Parsing

The Cloudflare Workers runtime supports the Web Crypto API but does not include Node.js crypto modules or third-party DER parsers like asn1.js. Bundling such a library would add significant size to the Worker. The certificate structure we need to parse is small and well-defined — six field skips and one SEQUENCE extraction. Hand-rolling avoids the dependency entirely.