Purchase tokens: hashing and encryption
A purchase token is a bearer credential, not an opaque id. This chapter covers the two records you actually keep — a hash for lookup, an encrypted copy for refresh — and the one you never keep.
A purchase token is a bearer credential
When a purchase completes, the store hands your app a string. On Google Play it is the purchaseToken; on the App Store it is a transaction identifier alongside a signed StoreKit 2 JWS transaction. It is tempting to treat that string like a database id — something to log, pass around, and store in a column.
It is not an id. Whoever holds the string can present it to the store's API and to your verification endpoint. Nothing else is required: no user identity, no device binding, no second factor. That is the definition of a bearer credential, and it has three consequences that shape every design decision in this chapter:
- A stolen token is a redeemable purchase. If it leaks from a log file, a crash report, or a database dump, an attacker can submit it to your verify endpoint under a user id they control and claim someone else's entitlement.
- It stays valuable. A subscription token remains the handle used to re-check state for the life of the subscription, so a token exfiltrated today is still useful months later — unlike a short-lived session token.
- You cannot avoid handling it. Verification, renewals, and store notifications all require the raw value at the moment of the call. The goal is not to never see it; the goal is to never persist it.
So the storage question is not "how do we protect the token column" — it is "what is the minimum we can write down that still lets the system work". The answer turns out to be two separate derivations with two different jobs.
Hash for dedup: the lookup key
The first job is lookup. Your server needs to answer "have I seen this exact token before?" and it needs to answer it constantly, because the same token legitimately arrives more than once:
- The client retries a verify call after a network timeout, or the user reinstalls and the app restores and re-submits the purchase.
- A store notification arrives carrying the same token — Google Play RTDN payloads reference the purchase token directly, so the notification handler must resolve it back to a row you already own.
- An attacker replays a token they obtained elsewhere, under a different user id, hoping to be granted the entitlement.
- A plan change issues a new token whose verified response links back to the superseded one, so the old token has to be resolvable to retire the entitlement it granted.
All four need the same primitive: a stable key derived from the token that can be compared and indexed. A cryptographic hash gives you that without keeping the credential. Tierux uses SHA-256, hex-encoded, and stores the result as purchaseTokenHash on the purchase and entitlement records.
// The hash is the key — the raw token is never the key. const purchaseTokenHash = sha256(input.purchaseToken); const existingPurchase = await store.findPurchaseByTokenHash(appId, purchaseTokenHash); // Same token, different subscriber → reject before any write. if (existingPurchase && existingPurchase.userId !== input.userId) { throw new AppError('TOKEN_ALREADY_LINKED_TO_ANOTHER_USER', 409, ...); }
Note what the lookup is actually for. It is not a blunt "seen it, skip it" filter — a legitimate re-submission by the same user still re-verifies against the store and records a fresh verification for that same purchase (deduplicated at read time), which is what makes the endpoint idempotent rather than a cache. The hash lookup exists to enforce an identity boundary (a token already linked to one subscriber can never grant to another) and to distinguish a first-time grant from a repeat, which matters for plan-change supersession and for plan limits.
Why a hash rather than the raw value? Because the lookup is the reason a naive design ends up with a token column in the first place. Once the lookup key is a hash, the raw token has no remaining reason to be written down for reads, and a database dump yields SHA-256 digests of high-entropy store-issued strings — not credentials an attacker can replay. The same derivation runs on the notification path, so an inbound webhook hashes the token it carries and finds the row without either side ever storing the original.
Encrypt for refresh: the recoverable copy
A hash is one-way, and that is a problem for exactly one workflow: refresh. To re-check a subscription's state you have to call the store's API again, and the store's API takes the raw token — it will not accept a digest. Renewals, expiry changes, cancellations, and any "re-sync this user's entitlements" operation all need the original string back.
That forces a genuine trade-off, and it is worth being explicit about it rather than pretending it away. The options are: store the token in plaintext and accept that a database compromise is a credential compromise; store nothing and lose the ability to refresh without the client re-submitting; or store a copy encrypted under a key that does not live in the database.
Tierux takes the third. Alongside the hash, a second field purchaseTokenEncrypted holds an AES-256-GCM ciphertext of the raw token, produced with a purpose-scoped key that is held by the server, not by the datastore:
// Written once, at grant time — next to the hash, never instead of it. purchaseTokenHash: sha256(input.purchaseToken), purchaseTokenEncrypted: encryptSecret(input.purchaseToken, 'purchase-token'), // Read back only on a store re-query — a refresh call, or a stateful RTDN re-verification — then discarded. const token = decryptSecret(purchase.purchaseTokenEncrypted, 'purchase-token');
Four properties of that scheme are load-bearing:
- Authenticated encryption. GCM produces a 16-byte authentication tag that is verified on decrypt, so a tampered ciphertext fails loudly instead of decrypting to attacker-chosen bytes. A mode without authentication would let anyone with write access to the row steer what your server sends to the store.
- A fresh IV per record. Each encryption draws a new random 12-byte initialization vector. GCM catastrophically loses confidentiality if an IV is reused under the same key, so this is not optional hygiene. The stored envelope is
iv.tag.ciphertext, base64url-encoded and dot-separated, which keeps the IV and tag with the record they belong to. - A purpose-scoped derived key. The master secret is not used directly. It is stretched through HKDF-SHA-256 into a distinct 256-bit key per purpose — the
'purchase-token'key is a different key from the one protecting store service-account credentials or webhook secrets. One compromised context does not unlock the others. - The key is configuration, not data. It comes from the
PURCHASE_TOKEN_ENCRYPTION_KEYenvironment variable and must be a high-entropy value of at least 32 characters — a human-chosen passphrase does not qualify, and a short value is rejected the first time it is used to encrypt or decrypt — not at process startup — rather than silently accepted. If the variable is unset, no encrypted copy is written at all and refresh simply skips those rows: the system degrades to "cannot refresh" rather than to "stored a credential in plaintext".
The decrypted value is transient. It exists as a local variable for the duration of one store call and is never returned to a client, never echoed into a response, and never logged. If decryption fails, the failure is recorded by product and user — not by token — and that one purchase is skipped rather than failing the whole refresh.
What is never written to disk
The invariant is short enough to state in one line: raw purchase tokens are never persisted; a hash is kept for dedup and an AES-256-GCM encrypted copy for refresh. In practice it has to hold in four places, not one.
| Value | Persisted? | Why |
|---|---|---|
| Raw purchase token / signed transaction | No | Bearer credential — a dump of it is a dump of redeemable purchases. |
| SHA-256 hash of the token | Yes | Lookup key for dedup, identity boundary, and plan-change supersession. |
| AES-256-GCM encrypted copy | Yes | The only way to recover the raw value for a later refresh call to the store. |
| Store verification response | Summarised | Only the decided fields are kept — validity, active flag, status, expiry — not the raw payload. |
| Error and audit logs | Hash only | Verification failures log the token hash so an incident is traceable without the credential. |
Persisted = written to durable storage. The raw token exists in memory during a verification or refresh call and nowhere else.
The two rows worth dwelling on are the last ones, because they are where this invariant usually breaks in a hand-rolled implementation. Storing the store's entire verification response "for debugging" quietly reintroduces the token into your database; keeping a summary of the decided fields does not. And the single most common leak is a log line — an exception handler that dumps its input, or a request logger that captures the body. Logging the hash instead costs nothing and keeps the trail useful.
The same shape applies across stores rather than being a Google Play special case: the App Store path hashes the transaction identifier and encrypts the signed JWS transaction under the same purpose-scoped key. Coverage depth differs by platform — Google Play is the primary, most production-hardened path and Apple App Store support is implemented with limitations; see the platform support matrix for per-feature status.
Purchase token
The one-paragraph definition — what the string is, which store issues it, and what it proves.
Docs: purchase token validation
The operational reference: dedup flow, the encryption environment variable, and per-store verification calls.
Purchase tokens are credentials. Treat them like it.
The case for this chapter's pattern, in one post: replay and cross-user redemption risk, and why hash-for-dedup plus encrypt-for-reuse is the fix.
Security
How this hashing-and-encryption pattern fits the rest of Tierux's security posture: no client-side API key, encrypted storage, and server-side verification throughout.
Verify tokens server-side, never store them raw
Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.
Start free