Skip to content

Backend Security

Postgres Bytea + AES-256-GCM — How Velocity X Encrypts OAuth Tokens You Can't Decrypt Without the Key

Encrypt at Rest, Decrypt in Memory

🔐 🔑 💾

Your database gets breached. Happens. The attackers dump every table, crack every password hash, and find a folder called oauth_tokens with thousands of refresh tokens. They own your users. Meanwhile, across the world, your CISO gets a call: "How encrypted was the storage?" If you answer "not at all," you've just handed over the keys to every customer account. Velocity X encrypts every OAuth refresh token with AES-256-GCM before it ever touches disk. The database stores ciphertext as a Postgres bytea hex blob. The key lives in a Netlify env var, not in the repository. If attackers steal the database, the tokens are noise.

This is how we built it, and why the bytea encoding nearly broke us.

Why Encrypt Tokens at Rest

OAuth refresh tokens are long-lived secrets. They expire in days or months, not hours. A leaked access token is bad; a leaked refresh token is a master key. The attacker can refresh it silently, impersonate your app, and read or modify every customer's data. Encryption at rest doesn't prevent the breach, but it makes the stolen data worthless without the encryption key. If your key lives separately (Netlify env vars, AWS Secrets Manager, HashiCorp Vault), the attacker needs two pieces: the database dump AND the key. Defense in depth.

Velocity X uses AES-256-GCM: 256-bit keys, authenticated encryption, and per-token randomness. Every token gets a unique initialization vector (IV), so identical plaintext tokens produce different ciphertexts. The authentication tag prevents tampering — if an attacker modifies a single byte, decryption fails.

AES-256-GCM Implementation in Node.js

Node.js ships with crypto. No dependencies. Here's the pattern:

import crypto from 'crypto';

const ENCRYPTION_KEY = Buffer.from(process.env.OAUTH_TOKEN_KEY, 'hex');
const ALGORITHM = 'aes-256-gcm';

export function encryptToken(plaintext) {
  const iv = crypto.randomBytes(12); // 96-bit IV for GCM
  const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);

  let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
  ciphertext += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  // Format: [iv + authTag + ciphertext]
  const combined = Buffer.concat([iv, authTag, Buffer.from(ciphertext, 'hex')]);
  return combined.toString('hex');
}

export function decryptToken(encryptedHex) {
  const combined = Buffer.from(encryptedHex, 'hex');

  const iv = combined.subarray(0, 12);
  const authTag = combined.subarray(12, 28); // 16 bytes
  const ciphertext = combined.subarray(28);

  const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
  decipher.setAuthTag(authTag);

  let plaintext = decipher.update(ciphertext, 'hex', 'utf8');
  plaintext += decipher.final('utf8');

  return plaintext;
}

The IV is random for every encryption. The auth tag is computed over the IV and ciphertext, so even a single bit flip is detected. The combined blob (IV + tag + ciphertext) is stored. On decryption, we extract each piece, verify the tag, and return plaintext. If the tag doesn't match, decipher.final() throws.

The Postgres Bytea Hex Gotcha

Here's where we lost a day. Postgres has two bytea formats: hex and escape. The hex format uses \x prefix and is faster. The escape format uses backslash sequences and is older. We chose hex for speed. But when we tried to store our encrypted blobs, we made the classic mistake: passing the hex string directly to Postgres without the ::bytea cast.

// WRONG — Postgres treats this as a string, not binary
INSERT INTO oauth_tokens (provider, encrypted_token)
VALUES ('hubspot', '4a7b2244626167...')

// RIGHT — explicit hex bytea cast
INSERT INTO oauth_tokens (provider, encrypted_token)
VALUES ('hubspot', '\x4a7b2244626167...'::bytea)

The \x prefix tells Postgres "this is hex, decode it to binary." Without it, Postgres stores a 256-character text string instead of a 128-byte blob. When we tried to retrieve it, the decryption function got a mis-formed buffer and crashed. The error message was cryptic: "Unexpected end of file in hex literal."

The second gotcha: never use JSON serialization. If you Buffer.toJSON(), Postgres sees `{"type":"Buffer","data":[...]}` — a string, not binary. Always use .toString('hex') and store with the \x prefix.

Here's the correct pattern:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);

export async function storeToken(orgId, provider, plaintext) {
  const encryptedHex = encryptToken(plaintext);

  const { error } = await supabase
    .from('oauth_tokens')
    .insert({
      org_id: orgId,
      provider,
      encrypted_token: encryptedHex, // Supabase auto-converts hex strings to bytea
    });

  if (error) throw error;
}

export async function retrieveToken(orgId, provider) {
  const { data, error } = await supabase
    .from('oauth_tokens')
    .select('encrypted_token')
    .eq('org_id', orgId)
    .eq('provider', provider)
    .single();

  if (error) throw error;

  // Supabase returns the bytea as a hex string by default
  return decryptToken(data.encrypted_token);
}

Supabase's JavaScript client handles the hex conversion for you — pass a hex string, it stores it with \x prefix and returns it as a hex string on read. If you're using raw SQL, use the prefix yourself: INSERT INTO oauth_tokens (encrypted_token) VALUES ('\x...'::bytea).

Key Rotation Strategy

Encryption keys should rotate every 90 days (or sooner if compromised). Rotate without breaking existing tokens:

1. Add a new encryption key to your env vars: OAUTH_TOKEN_KEY_NEW.

2. Add a key_version integer column to the oauth_tokens table. Existing rows default to version 1.

3. When storing a new token, use key version 2. When decrypting, check the key_version and use the appropriate key.

4. Run a background job: query all tokens with key_version = 1, decrypt with the old key, re-encrypt with the new key, and update key_version = 2. Once all tokens are migrated, retire the old key.

5. Set a reminder to rotate again in 90 days.

Frequently Asked Questions

What if the Netlify env var gets leaked?

The encrypted tokens become worthless, but your Netlify team can see env vars in the dashboard. Restrict access to the project settings page via Netlify team roles. Store the key in a secrets manager (AWS Secrets Manager, HashiCorp Vault) and load it at boot, not as an env var. Velocity X uses Netlify env vars because they're separate from git; migrate to Vault if your threat model demands it.

Why AES-256-GCM and not ChaCha20-Poly1305?

Both are secure. GCM is hardware-accelerated on modern CPUs and faster for most workloads. ChaCha20 is better on older/embedded hardware. Stick with GCM unless you have a specific reason not to.

Can I use a passphrase instead of a 256-bit key?

Yes, if you derive the key with a key derivation function (KDF). Use crypto.scryptSync(password, salt, 32) (32 bytes = 256 bits). Store the salt alongside the ciphertext so you can re-derive the key. GCM+KDF is slightly slower but works fine for OAuth tokens.

What happens if decryption fails?

The decipher.final() call throws an error. Catch it, log it (don't log the token), and return an error to the caller: "Token decryption failed; re-authorize your account." Never silently fall back to plaintext.

Does this work with Postgres JSON columns?

Yes. Store the encrypted hex string in a JSON field: { "encrypted_token": "4a7b..." }. When reading, extract the string and decrypt. JSON doesn't add security, but it's useful if you store multiple fields (encrypted_token, expires_at, provider) in one column.

Should I encrypt the IV?

No. The IV must be random and unique, but it doesn't need to be secret. Including it in the ciphertext blob (as we do above) is standard and safe. If someone knows the IV but not the key, they still can't decrypt the message.

The Bottom Line

OAuth tokens are crown jewels. Store them encrypted at rest with AES-256-GCM, one IV per token, and keep the key separate from the database. Postgres bytea hex format is fast and standard, but use the \x prefix or let your client library handle the conversion. If the database gets breached, attackers get noise. Check the pricing to see how Velocity X applies this pattern across all integrations, or read our HubSpot OAuth integration post for the real-world context where this encryption happens.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.