Blog

Purchase tokens are credentials. Treat them like it.

August 27, 2026 · Tierux Team

A Google Play purchase token or an App Store signed transaction is not a receipt in the "keep it for your records" sense. It's a bearer credential: whoever presents it to the store's verification API can prove — and in some flows, redeem — a purchase. Store one in a plaintext column next to the rest of your subscriber data and you've created a table that's as sensitive as a password table, without anyone treating it that way.

What goes wrong when tokens are stored raw

Two failure modes show up in practice, and both are worse than a typical data leak because a purchase token doesn't just disclose information — it can be replayed.

The pattern: hash for dedup, encrypt for reuse

The fix isn't "never store anything about the token" — you still need to detect duplicate submissions and, for lifecycle sync, re-query the store's API against the same token later. The fix is to never store the raw token, and to use two different derived forms for two different jobs:

import { createHash } from 'node:crypto';
export const sha256 = (value: string): string =>
  createHash('sha256').update(value).digest('hex');

The SHA-256 hash is a one-way fingerprint. It answers "have I seen this exact token before?" — the question your dedup and identity-boundary checks need answered — without ever letting anyone reverse it back to the original token. This is what backs every lookup in Tierux's entitlement store: findPurchaseByTokenHash(appId, sha256(purchaseToken)).

But hashing is one-way, and re-verifying a subscription's current state against the Play or App Store API requires the original token — a hash can't be un-hashed and handed back to Google. For that, Tierux keeps a separate AES-256-GCM encrypted copy, purpose-scoped so a key compromise in one context can't be reused in another:

const deriveKey = (secret: string, purpose: KeyPurpose): Buffer =>
  Buffer.from(
    hkdfSync('sha256', Buffer.from(secret, 'utf8'), APP_SALT,
      `${APP_SALT}:${purpose}`, 32),
  );

export const encryptSecret = (value: string, purpose: KeyPurpose): string | null => {
  const key = deriveKey(secret, purpose);
  const iv = randomBytes(GCM_IV_BYTES);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return [iv, tag, encrypted].map(b => b.toString('base64url')).join('.');
};

Every encryption purpose (purchase-token, gp-service-account, webhook-secret, and others) derives its own key via HKDF from one master secret, so the encrypted purchase-token column can't be decrypted with, say, the service-account key even if both live in the same database. The result: two columns per purchase — a hash for identity/dedup, and an encrypted blob for the rare re-query path (RTDN plan-change resolution, for example, decrypts the stored token only when it needs to re-query Play for a superseded purchase's current state). The raw token itself never lands on disk.

Zero-trust the client, always

None of this matters if the server trusts whatever the client claims about the purchase. The invariant worth designing for is: userId should come from a verified identity token, never from the request body, and the purchase's validity is decided entirely server-side by calling the store's API — not by trusting a client-reported "purchased" flag. A modified or rooted client can lie about anything except what the store itself will confirm when your server asks it directly.

This is also why the free, public /verify endpoint needs its own defense-in-depth: since anyone can call it, a stolen token could otherwise be redeemed under an attacker-chosen userId before the legitimate device ever gets a chance to verify. When the client sets an obfuscated account id at purchase time, that value must match what the store reports for the token before the mismatch is allowed to pass. Absent on either side, verification falls through unchanged, so the check never breaks clients that don't set the field — but when both sides are present, they must agree.

On authenticated client routes with enforceAttestation turned on, identity is derived from the verified ID token instead: a request body may omit userId, but if it includes one, it must equal the verified UID or the request is rejected. Without enforceAttestation enabled, that same route falls back to whatever userId the body supplies — which is exactly why the public-path check above still matters.

What this looks like end to end

  1. Client purchases, gets a raw token from the store SDK.
  2. Client sends the token to your backend. Never expose your service-account credentials or your API key to the client.
  3. Server verifies the token against the store's API, independent of anything the client claims.
  4. Server persists a SHA-256 hash (for future dedup/identity lookups) and an AES-256-GCM encrypted copy (for re-query on refresh) — never the raw value.
  5. Server derives entitlement state and returns only the result — { active: true, entitlement: "pro" } — never the token itself.

A subscription backend that follows this pattern treats purchase tokens the same way a well-built auth system treats passwords: never stored in a form that's directly usable if the database leaks, but still functionally available for the operations that legitimately need them.

Related reading

Purchase token glossary entry

The short reference definition of what a purchase token actually is on each platform.

Server-side purchase verification: the definitive guide

Why verification has to happen server-side in the first place, and the common pitfalls of building it yourself.

Security

How Tierux handles key management, encryption, and the rest of the trust boundary.

Never store a raw purchase token again

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

Start free