← Back to Blog

Encrypting Third-Party API Tokens at Rest When Your Edge Runtime Has No crypto Module

When a user connects their HubSpot portal to Smuves, we receive an OAuth access token. That token is the key to their entire CMS. It can read every page, every blog post, every HubDB table. It can write to all of them. If someone gets access to that token, they get access to the portal.

Storing that token in plain text was never an option. But encrypting it turned out to be more complicated than expected, not because encryption is hard, but because the runtime where we need to decrypt it does not support the standard tools for doing so.

This post covers how we encrypt HubSpot tokens at rest, why the key derivation works the way it does, and the real-world maintenance cost of reimplementing crypto in every edge function.

The requirements

We needed four things from the encryption scheme. First, tokens had to be encrypted before they hit the database. If someone gained read access to the Postgres table, they should see ciphertext, not usable tokens. Second, each token had to be encrypted with a key unique to the user and portal combination. Compromising one key should not compromise every token in the system. Third, the same plaintext encrypted twice should produce different ciphertext. Otherwise an attacker who sees two identical ciphertext values knows the underlying tokens are the same, which leaks information. Fourth, decryption had to work inside Supabase Edge Functions, which run on Deno and cannot import Node.js built-in modules.

The first three requirements are standard. The fourth one is what made this interesting.

The encryption scheme

We use AES-256-CBC. It is a symmetric block cipher that has been around for decades, is well understood, and is supported by every major crypto library. The "256" means a 256-bit key. The "CBC" means Cipher Block Chaining, a mode of operation where each block of plaintext is XORed with the previous ciphertext block before encryption, which means identical plaintext blocks produce different ciphertext.

For each encryption operation, we generate a random 16-byte initialization vector (IV). The IV is prepended to the ciphertext and stored alongside it, separated by a colon. So the stored value looks like iv:ciphertext, both base64 encoded. During decryption, we split on the colon, extract the IV, and use it along with the key to reverse the encryption.

The random IV is what satisfies the third requirement. Even if you encrypt the same token twice with the same key, the random IV ensures the ciphertext is different each time.

Per-user key derivation

The encryption key is not a single application-wide secret. It is derived per user and per portal. We concatenate the user ID, the portal ID, and a fixed salt string, then hash the result with SHA-256 to produce a 256-bit key.

The salt string is constant across all key derivations. Its purpose is not to add randomness. That is what the IV does. The salt ensures that the derived key is specific to our application. If someone else happened to concatenate the same user ID and portal ID for a different purpose, they would not produce the same key because they would not know the salt.

This approach means that two different users connected to the same portal have different encryption keys. And the same user connected to two different portals also has different keys. Compromising one key reveals one token for one user on one portal. Nothing else.

The tradeoff is that key derivation depends on knowing the user ID and portal ID at decryption time. If either value changes, the derived key changes, and the stored ciphertext becomes undecryptable. In practice this is not a problem because user IDs and portal IDs are immutable in our system. But it is worth being aware of if you adopt a similar scheme.

The Deno problem

In our Next.js application, encryption and decryption use the Node.js crypto module. It ships with Node, it is fast, and we wrote the implementation once. That single module handles every encryption and decryption call that happens during API route handling.

But the most important decryption calls do not happen in the Next.js app. They happen in Supabase Edge Functions. When the fetch worker runs, it needs to decrypt the HubSpot token before it can call the HubSpot API. When the export worker runs, it needs to decrypt the token for the same reason. These workers are Deno edge functions, and Deno does not have access to the Node.js crypto module.

Deno has its own standard library for cryptography, and it has the Web Crypto API. Both can handle AES-256-CBC. But the API surface is different. You cannot import Node crypto and call createDecipheriv. You have to use the Web Crypto subtle API, which is promise-based and has a different way of specifying algorithms, key imports, and IV handling.

We could have written a shared library that abstracts over both runtimes. In theory, that is the right thing to do. In practice, Supabase Edge Functions have constraints on how you bundle and import code, and at the time we built this, sharing modules between the Next.js app and the edge functions was not straightforward.

So we did the pragmatic thing. We reimplemented the decryption logic inline in each edge function that needs it. The key derivation is identical. The algorithm is identical. The IV extraction is identical. The only difference is the API calls used to perform the actual AES operation.

The maintenance cost of duplicated crypto

We are going to be direct about this part because it is the least comfortable aspect of the architecture.

The encryption logic currently exists in four places. Once in the shared Node.js encryption module used by the Next.js app. And then reimplemented from scratch in three separate edge functions: the content fetch worker, the job creation function, and the HubSpot fetch worker.

All four implementations do the same thing. They derive the same key from the same inputs, use the same algorithm, and produce or consume the same ciphertext format. If you change the key derivation scheme in one place and forget to change it in the others, decryption silently fails. The token cannot be recovered, and the user sees a generic API error with no indication that the root cause is a crypto mismatch.

We mitigate this with a few habits. Any pull request that touches the encryption module triggers a manual review of all four implementations. We have integration tests that encrypt a token in the Node module and decrypt it using the Deno implementation to verify compatibility. And the key derivation parameters are stored as constants that are easy to grep for across the codebase.

None of this is a substitute for having the logic in one place. We know that. The plan is to extract a shared module once the Supabase Edge Functions bundling story improves enough to support it cleanly. Until then, the duplication is a known cost that we manage actively rather than pretend does not exist.

What happens if the encryption secret is lost

There is no master key that can decrypt all tokens. The encryption key is derived at runtime from the user ID, portal ID, and salt. As long as those three values are available, the key can be recomputed and the token can be decrypted.

If the salt value is lost, every stored token becomes unrecoverable. This is the one catastrophic failure mode. The salt is stored as an environment variable, and we treat it with the same care as a database password. It is set once and never rotated. Rotating it would require re-encrypting every stored token with the new salt, which is possible but operationally expensive.

Users can always reconnect their HubSpot portal to generate a new OAuth token, which would then be encrypted with the current salt. So even in the worst case, the recovery path is to ask users to reconnect. It is not ideal, but it is recoverable.

Why not a key management service

The obvious question is why we did not use a proper key management service like AWS KMS, Google Cloud KMS, or HashiCorp Vault. The answer is that all of those services add latency and cost to every decryption operation, and our edge functions decrypt tokens on every single invocation. At hundreds of invocations per day, the KMS round-trip latency and per-request pricing would add up.

More importantly, our threat model does not require it. The tokens are encrypted at rest in the database. The encryption key cannot be derived without knowing the salt, which is stored separately from the database. An attacker who compromises the database sees ciphertext. An attacker who compromises the environment variables can derive keys, but they would also need the ciphertext from the database. You need both to get a usable token.

If our threat model changes, say we start storing tokens for enterprise clients with stricter compliance requirements, we will revisit this. KMS is the right answer when you need hardware-backed key storage, audit logging of every decryption event, and automatic key rotation. We do not need those things today.

What we would tell other teams

If you are building a SaaS product that stores third-party OAuth tokens, encrypt them at rest. This is not optional. A database breach that leaks plaintext API tokens for your customers is an existential event for a startup.

Per-user key derivation is worth the small additional complexity. A single application-wide key means a single point of failure. Per-user keys contain the blast radius.

If your decryption happens in a runtime that does not share modules with your main application, budget time for maintaining parallel implementations. It is an ugly tradeoff, but it is better than storing tokens in plain text while you wait for the perfect abstraction.

And test the round trip. Encrypt in one runtime, decrypt in the other, and verify the result. Do this in CI, not just manually. A crypto mismatch between your runtimes will not throw a helpful error. It will just produce garbage, and you will spend hours figuring out why the HubSpot API is returning 401s.