← All writing
Shopify Apps

Encrypting Shopify access tokens at rest (and why I did it when I didn't have to)

Keith Pillay · 3 October 2026 · 3 min read

Every Shopify app stores an access token per store. That token is the keys to the shop's API, within the scopes you were granted. If someone gets a database dump with plaintext tokens, they get working credentials for every merchant.

I wasn't required to encrypt them. I did it anyway. Here's the reasoning and the design.

Was it required?

Shopify's "encrypt data at rest" expectations are scoped to protected customer data. My app, the Product Health Scanner, never reads or stores customer data, only products, with read-only access.

So there's no rule that forced this. But storing a token in plaintext is a security gap on general principle: the token grants direct API access to the whole store within its scope. Closing that gap was cheap, so I closed it.

The design

Algorithm: AES-256-GCM. It's authenticated encryption, which means a tampered ciphertext is rejected rather than silently decrypted into garbage. That property matters more than people expect.

Format: the stored value is the initialisation vector, the authentication tag and the ciphertext, each base64-encoded and joined. A fresh random IV for every encryption means the same token encrypts to a different value each time.

Where it hooks in: I wrapped the session storage layer. The wrapper encrypts the access and refresh tokens on the way in, and decrypts them on the way out. Everything above it sees normal sessions. Everything below it, including the database, only ever sees ciphertext.

Where the key lives

This is the part that makes encryption mean something. If the key sits next to the data, you've only added a step for the attacker.

The session table lives in the same database as everything else, which is unavoidable because session lookups happen on every request. But the key is a separate secret that never touches the database or git:

  • Local development: an environment variable in a git-ignored file.
  • Production: a secret manager, mounted into the runtime as an environment variable, so the application code is identical in every environment and only the deployment configuration decides where the value comes from.

I considered object storage for the key and rejected it. It's the wrong tool for a small secret read on every request. A secret manager is built for exactly that.

A safe upgrade path

Existing sessions had been stored in plaintext before this change. The decrypt function treats anything not in the expected format as legacy plaintext and returns it unchanged, so old sessions keep working and get re-encrypted the next time Shopify refreshes the token. No forced logout, no migration script, and no crash on old data.

Testing it properly

I wrote 17 tests: round trip, empty values, random IV for identical plaintext, legacy passthrough, tampered ciphertext rejected, wrong key rejected, missing or wrong-length key errors, and tests of the storage wrapper using a fake inner storage to assert that the inner storage never sees a plaintext token.

Then I broke it on purpose: bypassing encryption in the store path made exactly the two "ciphertext, not plaintext" tests fail, and I restored it.

On the real development store, I confirmed through a direct database query that the existing session had been re-stored as ciphertext (a long opaque string, not a recognisable token), and that a full scan still completed through the encrypted session. That proved the round trip works against the real API and database, not just a fake.

Takeaways

  1. "Not required" and "not worth doing" are different things.
  2. Encryption is only as good as where the key lives.
  3. Use authenticated encryption, so tampering fails loudly.
  4. Make the upgrade path safe for existing data.
  5. Prove it by breaking it.

This is one of the details from Part 2 of the build story.

Hiring a senior Shopify developer?

I'm open to remote roles worldwide. Send a message and I'll reply within a day.