When you build a tool that stores authentication tokens for other people's social media accounts, you have exactly one job before anything else: make sure a database leak doesn't compromise every account you manage.
This is how we handle it at HelperX — an X automation platform where every slot stores an auth token and proxy credentials.
The threat model
Let's be honest about what application-level encryption protects against — and what it doesn't.
What it covers:
- Database dump stolen via SQL injection or backup leak
- Casual disk access (stolen server, improper decommission)
- Insider access to the database without code access
What it doesn't cover:
- An attacker with code execution on the server (they can read the key from environment)
- A compromised application process (it decrypts at runtime)
This is the standard threat model for SaaS applications. If someone owns your process, encryption at rest won't save you — but that's what defense in depth, access controls, and monitoring are for.
Our goal: even if the database leaks, tokens are unreadable.
Why AES-256-GCM
There are three realistic choices for symmetric encryption in Node.js:
- AES-256-CBC — works, but no built-in authentication. You need a separate HMAC to detect tampering.
- AES-256-GCM — authenticated encryption. Encryption + integrity check in one operation. If anyone modifies the ciphertext, decryption fails. This is what we use.
- ChaCha20-Poly1305 — excellent algorithm, but less hardware acceleration on x86 servers compared to AES-GCM.
GCM wins because:
- One operation for encryption + authentication (no separate HMAC step)
- Hardware-accelerated AES-NI on virtually all modern servers
- Battle-tested in TLS 1.3, widely reviewed
The encryption flow
Here's the conceptual flow — not our exact code, but the pattern:
const crypto = require('crypto');
function encrypt(plaintext, masterKey) {
// Every value gets its own random IV — never reuse
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
// GCM produces an auth tag — store it alongside the ciphertext
const authTag = cipher.getAuthTag().toString('hex');
// Return IV + authTag + ciphertext as a single string
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
function decrypt(stored, masterKey) {
const [ivHex, authTagHex, ciphertext] = stored.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
Key details:
- Random IV per value — the same token encrypted twice produces different ciphertext. This prevents an attacker from detecting duplicate tokens across slots.
-
Auth tag stored with ciphertext — if anyone tampers with the stored value,
decipher.final()throws. No silent corruption. -
Single string format —
iv:authTag:ciphertextkeeps everything in one database column. No schema changes needed.
Key management
The master key lives in an environment variable. Not in the database, not in the codebase, not in a config file that gets committed.
X_TOKEN_ENC_KEY=<64-char hex string>
Key derivation:
// Derive a 32-byte key from the environment variable
const masterKey = crypto.createHash('sha256')
.update(process.env.X_TOKEN_ENC_KEY)
.digest();
We use SHA-256 to derive a fixed-length key from the environment variable. This means the env var can be any string — a passphrase, a hex string, a UUID — and we always get a valid 256-bit key.
Why not use the env var directly?
Environment variables are strings. AES-256 needs exactly 32 bytes. Hashing normalizes any input to the right length.
What gets encrypted
Not everything in the database is encrypted — only credentials:
- X auth tokens — the primary target
- Proxy credentials — username/password for residential proxies
Settings, daily counters, audit logs, module configuration — these are stored in plaintext. They're not sensitive, and encrypting them would add latency to every operation for no security benefit.
Decryption at runtime
Tokens are only decrypted when a module needs to make an API call on behalf of the user. The decrypted value lives in memory for the duration of the request, then gets garbage collected.
We don't:
- Cache decrypted tokens
- Write decrypted values to logs
- Pass decrypted tokens between processes
Each module cycle: read encrypted token → decrypt → use → discard.
Legacy data migration
When we added encryption, existing users already had plaintext tokens in the database. We handle this with a detection-and-upgrade pattern:
function getToken(stored, masterKey) {
// Encrypted values contain colons (iv:authTag:ciphertext)
if (stored.includes(':')) {
return decrypt(stored, masterKey);
}
// Legacy plaintext — encrypt it for next time
const encrypted = encrypt(stored, masterKey);
saveEncryptedToken(encrypted); // update in database
return stored;
}
On first access, plaintext tokens are automatically encrypted and saved back. No manual migration needed, no downtime, no batch job.
Performance impact
AES-256-GCM with AES-NI hardware acceleration:
- Encrypt: ~0.02ms per token
- Decrypt: ~0.02ms per token
For context, a single HTTP request to X's API takes 200–800ms. Encryption adds 0.02ms. It's unmeasurable in practice.
We encrypt/decrypt ~50,000 tokens per day across all slots. Total CPU time for encryption: about 1 second per day. Not a bottleneck.
What we'd do differently
If starting from scratch:
Use envelope encryption — encrypt each token with a unique data key, then encrypt the data key with the master key. This lets you rotate the master key without re-encrypting every token.
Consider a KMS — AWS KMS or HashiCorp Vault for key management instead of a raw environment variable. Adds operational complexity but improves the key lifecycle.
Field-level encryption in the ORM — encrypt/decrypt transparently at the model layer so developers never see plaintext tokens. We do this manually; a framework integration would be cleaner.
For our scale (thousands of slots, not millions), the current approach is sufficient. The improvements above are for teams that need to rotate keys frequently or operate under stricter compliance requirements.
Takeaways for your project
If you're storing third-party credentials in your SaaS:
- Use AES-256-GCM, not CBC — you get authentication for free
- Random IV per value — never reuse IVs with the same key
- Store IV + authTag + ciphertext together — one column, no schema overhead
- Key in environment, not in code — the simplest separation that works
- Encrypt only secrets — don't waste cycles on non-sensitive data
- Handle legacy data gracefully — detect-and-upgrade beats batch migration
The code is straightforward. The hard part is making it automatic so developers on your team can't accidentally skip it.
HelperX encrypts every auth token and proxy credential with AES-256-GCM before database storage. If you manage X accounts, we handle the security so you can focus on growth.
Top comments (0)