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.