Guide · Chapter 2 of 5

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:

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:

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:

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.

ValuePersisted?Why
Raw purchase token / signed transactionNoBearer credential — a dump of it is a dump of redeemable purchases.
SHA-256 hash of the tokenYesLookup key for dedup, identity boundary, and plan-change supersession.
AES-256-GCM encrypted copyYesThe only way to recover the raw value for a later refresh call to the store.
Store verification responseSummarisedOnly the decided fields are kept — validity, active flag, status, expiry — not the raw payload.
Error and audit logsHash onlyVerification 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.

Related reading

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.

← Back to the server-side verification guide

Verify tokens server-side, never store them raw

Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.

Start free