Signing Apple promotional offers with JWS
We wanted to give existing subscribers a promotional price. Someone who already subscribed before, has lapsed or is about to, and we want to win them back with a discount. On Apple, you cannot just flip a price. The server has to send Apple a signed token that says “this user is allowed this offer,” and Apple checks the signature before honoring it.
The signature format is JWS. There is a fun continuity here for me: a couple of years back I was on the other side of this, verifying signed callbacks from an ad network. Now I am the one doing the signing.
What JWS is, briefly
JWS stands for JSON Web Signature. The idea is the same as any signature scheme. You have some data, you sign it with a private key, and anyone with the matching public key can confirm the data is genuine and unchanged.
What JWS adds is a standard layout. A JWS has three parts joined by dots:
header.payload.signature
The header says how it was signed and which key was used. The payload is the data you are vouching for. The signature is computed over the header and payload together with your private key. Apple holds the public side (you register your key with them), so they can verify what you send.
For these offers I was on the signing side, which means I needed three things right: the key, the key id, and a nonce.
The key is the private signing key Apple gave us a key id for. The key id goes in the header so Apple knows which of your keys to verify against, which also means you can rotate keys without breaking everything. The nonce is a one-time value tied to the request so the same signed token cannot be replayed later for a different transaction.
The signing itself is short. Most of the work is assembling the exact payload Apple expects and not fumbling the key handling.
header = {
alg: "ES256",
kid: APPLE_KEY_ID,
}
payload = {
productId: offer.product_id,
offerId: offer.id,
nonce: SecureRandom.uuid,
timestamp: (Time.now.to_f * 1000).to_i,
# plus the other fields Apple requires
}
jws = JWT.encode(payload, signing_key, "ES256", header)
ES256 means the signature uses elliptic-curve crypto with SHA-256, which is what Apple wants here. The thing that bit me, same as last time on the verifying side, was getting the payload fields and their order exactly as specified. A signature that is cryptographically fine still gets rejected if the payload is not assembled the way the other side expects.
Eligibility is its own problem
Signing the token is only half of it. The other half is deciding who gets the offer at all, and that turned out to need more care than the crypto.
The offer was meant for returning users: people who had subscribed before. A brand-new user who never subscribed should not get the win-back price, because then it is not winning anyone back, it is just a discount for everyone. So before signing anything, I check eligibility on our side: does this user have a subscription history that qualifies, are they in the window we are targeting, have they already used this offer.
The reason this matters is that signing is a statement of trust. When my server signs the token, it is telling Apple “yes, this person qualifies.” If my eligibility check is loose, I am signing off on offers that should never have gone out, and the signature does not save me there. The crypto proves the message came from us. It says nothing about whether we should have sent it.
So the order is: check eligibility first, and only sign if it passes. The signature is the last step, not the gate.
The two sides of a signature
It was a nice thing to notice that the same primitive shows up on both ends of my work, years apart. Back then I held a public key and checked that incoming callbacks were genuine. This time I hold a private key and produce tokens that someone else checks. It is the same crypto, just from the other side.
The practical takeaways are the same on both sides though. Get the signed bytes exactly right, treat the key id as the thing that lets you rotate keys safely, and remember the signature only proves where a message came from. Whether the message should exist at all is a separate decision you still have to make.