I built this pattern because I needed to store credentials that my app has to use later: API keys, OAuth refresh tokens, webhook secrets, and third-party service passwords. These are not user login passwords. That distinction matters. If the value only needs to be checked, hash it. If the app has to read the original value again, encrypt it.
The goal was simple: store sensitive credentials in the database without leaving plain text sitting there, while keeping the implementation small enough that I could explain every piece of it later. AES-256-GCM ended up being the right fit because it gives us encryption and integrity checking in one operation. If someone edits the encrypted blob in the database, decryption fails instead of returning garbage.
The shape of the pattern
I started by deciding what the encrypted record should look like before writing any encryption code. That helped keep the rest of the work grounded. I wanted every stored credential to carry the metadata needed to decrypt it later, without storing the secret key itself.
The final payload looks like this:
{
"version": 1,
"alg": "AES-256-GCM",
"keyId": "cred-key-2025-01",
"iv": "base64...",
"tag": "base64...",
"ciphertext": "base64..."
}
That record can live as JSON in a database column, or it can be split into separate columns if that fits your schema better. I kept it as one JSON field because I wanted the credential table to stay clean while I was iterating.
| Field | What it does |
|---|---|
| version | Lets the app support future formats without guessing. |
| alg | Documents the algorithm used for this record. |
| keyId | Points to the encryption key used, but does not contain the key. |
| iv | A unique random value used for this encryption operation. |
| tag | The GCM authentication tag used to detect tampering. |
| ciphertext | The encrypted credential value. |
The first rule I wrote at the top of the file
Never reuse the same IV with the same AES-GCM key.
That one line shaped the implementation. AES-GCM is fast and practical, but nonce reuse is the footgun. For each credential encryption, I generate a fresh 96-bit IV using a secure random generator. I do not derive it from the user ID. I do not use timestamps. I do not increment a counter in app code. I let the crypto library generate random bytes.
For AES-GCM, a 12-byte IV is the normal choice. It maps cleanly to how GCM is designed and keeps the implementation boring, which is exactly what I want in crypto code.
Key storage was the real design decision
The encryption function is only half the story. The key has to live somewhere outside the database. If the encrypted credentials and the encryption key are both sitting in the same database backup, the encryption is mostly theater.
For production, I prefer a managed key service or a secret manager. The database stores the encrypted credential. The app gets access to the current encryption key through its runtime environment. Access to that key is controlled separately from database access.
For my local vibe-coded version, I used a 32-byte key loaded from an environment variable so I could build the flow end to end. That is fine for a local prototype. For a real deployment, I would move the key into a proper secret store and make sure it never lands in Git, logs, build artifacts, screenshots, or seed files.
Generating a development key
AES-256 needs a 256-bit key, which means 32 bytes. I store it as base64 in the environment because that is easier to move around than raw bytes.
openssl rand -base64 32
That gives me a value I can place in a local environment file during development:
CREDENTIAL_ENCRYPTION_KEY_BASE64="paste-generated-value-here"
CREDENTIAL_ENCRYPTION_KEY_ID="cred-key-dev-001"
I also keep a key ID next to it. That felt a little unnecessary at first, but it paid off once I started thinking about key rotation. Every encrypted record should say which key encrypted it. Future me will be grateful.
The encrypt function
Here is the basic Node.js version I built first. It uses the built-in crypto module, keeps the IV random, and returns a JSON-safe object.
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH_BYTES = 12;
function getCredentialKey() {
const key = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY_BASE64, "base64");
if (key.length !== 32) {
throw new Error("Credential encryption key must be 32 bytes.");
}
return key;
}
export function encryptCredential(plainText, aad = "") {
const key = getCredentialKey();
const iv = crypto.randomBytes(IV_LENGTH_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
if (aad) {
cipher.setAAD(Buffer.from(aad, "utf8"));
}
const ciphertext = Buffer.concat([
cipher.update(plainText, "utf8"),
cipher.final()
]);
const tag = cipher.getAuthTag();
return {
version: 1,
alg: "AES-256-GCM",
keyId: process.env.CREDENTIAL_ENCRYPTION_KEY_ID,
iv: iv.toString("base64"),
tag: tag.toString("base64"),
ciphertext: ciphertext.toString("base64")
};
}
The extra input called aad is associated authenticated data. It is not encrypted, but it is included in the integrity check. I use it to bind the encrypted credential to the thing it belongs to, such as a workspace ID, integration ID, or credential record ID.
That means if someone copies an encrypted credential from one row to another, decryption can fail because the surrounding context no longer matches. This is a small detail, but it makes the stored blob less portable for an attacker.
The decrypt function
Decryption mirrors encryption. Same algorithm, same key, same IV, same authentication tag, and the same AAD value. If any of those are wrong, the function throws.
export function decryptCredential(encrypted, aad = "") {
if (encrypted.version !== 1) {
throw new Error("Unsupported credential encryption version.");
}
if (encrypted.alg !== "AES-256-GCM") {
throw new Error("Unsupported credential encryption algorithm.");
}
const key = getCredentialKey();
const iv = Buffer.from(encrypted.iv, "base64");
const tag = Buffer.from(encrypted.tag, "base64");
const ciphertext = Buffer.from(encrypted.ciphertext, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
if (aad) {
decipher.setAAD(Buffer.from(aad, "utf8"));
}
decipher.setAuthTag(tag);
const plainText = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return plainText.toString("utf8");
}
The first time I wired this up, decryption kept failing. The bug was not in AES. It was me passing a different AAD string on read than I used on write. That was a good reminder to make the AAD value deterministic and boring. I now build it in one helper function instead of hand-writing it in multiple places.
function credentialAad({ workspaceId, credentialId }) {
return `workspace:${workspaceId}:credential:${credentialId}`;
}
How I store the encrypted value
My first version had a plain secret column. I replaced that with an encryptedSecret JSON column. I also added a short display label so the UI can show something useful without decrypting the credential.
{
"id": "cred_123",
"workspaceId": "ws_456",
"provider": "github",
"label": "GitHub token ending in 9f3a",
"encryptedSecret": {
"version": 1,
"alg": "AES-256-GCM",
"keyId": "cred-key-2025-01",
"iv": "base64...",
"tag": "base64...",
"ciphertext": "base64..."
},
"createdAt": "2025-01-10T18:42:00.000Z",
"updatedAt": "2025-01-10T18:42:00.000Z"
}
I do not store the full secret anywhere else. Not in an audit table. Not in an analytics event. Not in a job payload. When I need to hand the credential to another part of the system, I pass it in memory and keep logs away from it.
The write path
The write flow became pretty clean once the pieces were in place. Create the database record first, use its ID as part of the AAD, encrypt the credential, then update the record with the encrypted payload. If your database flow makes that awkward, generate the credential ID in app code before the insert.
- Validate the credential input and trim accidental whitespace if the provider allows it.
- Create or prepare the database record with a stable credential ID.
- Build the AAD from stable fields, such as workspace ID and credential ID.
- Encrypt the credential using AES-256-GCM.
- Store only the encrypted payload and safe display metadata.
- Return a response that never includes the raw credential.
That last step caught me once during testing. I had a debug response returning the object I used before saving, and that object still had the raw credential on it. I changed the handler to create a separate response object instead of reusing internal state. Small fix. Big relief.
The read path
The read path should be narrower than the write path. Most screens do not need to decrypt credentials. A settings page can show the provider, label, created date, and status without touching the secret at all.
I only decrypt at the edge where the credential is actually needed, like calling a provider API or refreshing an OAuth token. That keeps the raw value alive for the shortest practical amount of time.
- Load the credential row by ID and tenant boundary.
- Check authorization before decryption.
- Build the exact same AAD used during encryption.
- Decrypt the payload.
- Use the secret immediately.
- Do not log it, return it, cache it, or attach it to errors.
Handling key rotation without panic
Key rotation felt intimidating until I reduced it to two operations: read with old keys, write with the current key. That is why the keyId field exists.
The app should know which key is current for new writes. It should also be able to fetch older keys for existing records, at least during the rotation window. When a credential is updated, it gets encrypted with the current key. For old untouched records, a background job can decrypt with the old key and re-encrypt with the new one.
I like making rotation lazy at first. When a credential is successfully decrypted with an old key, the app can re-encrypt it with the current key after the main operation succeeds. That spreads the work out naturally. For systems with stricter controls, a dedicated migration job may be cleaner.
Failures I want to be boring
Crypto errors should not be dramatic in the app. If decryption fails, the user does not need a stack trace or a detailed message about authentication tags. The system should treat it as an unreadable credential, log a safe diagnostic event, and guide the user to reconnect or replace the credential.
- Do not print the encrypted payload in normal logs.
- Do not print the decrypted secret anywhere.
- Do not expose whether the IV, tag, key, or AAD was the specific failure point.
- Do record the credential ID, key ID, workspace ID, and failure type if those values are safe in your system.
- Do alert on repeated decrypt failures, because that may indicate a bad deploy, key mismatch, data corruption, or tampering.
I also added a test that encrypts a value, changes one character in the ciphertext, and confirms decryption fails. Then I did the same for the tag and AAD. Those tests are tiny, but they make the contract obvious.
The tests that gave me confidence
I did not try to test the AES algorithm itself. That belongs to the crypto library. I tested my wiring, because that is where I am most likely to make mistakes.
- Encrypting and decrypting returns the original credential.
- Two encryptions of the same credential produce different ciphertext because the IV changes.
- Changing the ciphertext causes decryption to fail.
- Changing the tag causes decryption to fail.
- Using the wrong AAD causes decryption to fail.
- A key that is not 32 bytes is rejected on startup or before use.
- An unsupported version is rejected.
The second test is my favorite one. It catches accidental static IVs immediately. If two encrypted payloads match exactly for the same input, something is very wrong.
A few choices I changed along the way
I originally thought about storing the IV and tag inside one packed string with the ciphertext. Something like iv.tag.ciphertext. That works, but I moved back to a JSON object because it is easier to inspect during development and easier to version later.
I also started without AAD because the examples online often skip it. Adding it took only a few lines, and it made the credential blob tied to its record context. That felt like a good trade.
Another course correction: I stopped decrypting credentials in general-purpose service functions. I now keep decryption close to provider-specific code. If the GitHub client needs a token, it asks for it there. The rest of the app passes credential IDs around, not secret values.
What this pattern does not solve
This pattern protects stored credentials if someone gets database read access without key access. It also detects tampering. That is useful, but it is not magic.
- If an attacker gets app runtime access, they may be able to decrypt credentials.
- If logs contain secrets, encryption at rest will not save you.
- If the encryption key is committed to the repo, rotate it and treat old data as exposed.
- If access control is loose, users may still trigger actions with credentials they should not control.
- If these are user login passwords, use password hashing instead of reversible encryption.
The pattern is one layer. I still need tenant checks, permission checks, log hygiene, secret scanning, safe backups, and a clean incident plan. Boring security layers are the ones I trust most.
The version I would ship
For a production app, I would ship this shape: AES-256-GCM, 12-byte random IV per encryption, 32-byte keys stored outside the database, a key ID on every encrypted record, AAD bound to stable record context, and narrow decryption paths. I would add tests around the wrapper and alerts around decrypt failures.
The nice part is that none of this needs a giant security platform to get started. The first pass can be small and readable. Build the wrapper. Use it in one credential flow. Remove plain text storage. Add tests. Then tighten key storage and rotation as the app gets closer to real users.
That is the build path I like: make the safe thing the default, keep the code understandable, and leave enough metadata in the encrypted record so future changes do not require guessing.