DEV Community

Cover image for How We Encrypt X Auth Tokens: AES-256-GCM in Practice
HelperX
HelperX

Posted on • Originally published at helperx.app

How We Encrypt X Auth Tokens: AES-256-GCM in Practice

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:

  1. One operation for encryption + authentication (no separate HMAC step)
  2. Hardware-accelerated AES-NI on virtually all modern servers
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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 formativ:authTag:ciphertext keeps 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>
Enter fullscreen mode Exit fullscreen mode

Key derivation:

// Derive a 32-byte key from the environment variable
const masterKey = crypto.createHash('sha256')
  .update(process.env.X_TOKEN_ENC_KEY)
  .digest();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. 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.

  3. 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:

  1. Use AES-256-GCM, not CBC — you get authentication for free
  2. Random IV per value — never reuse IVs with the same key
  3. Store IV + authTag + ciphertext together — one column, no schema overhead
  4. Key in environment, not in code — the simplest separation that works
  5. Encrypt only secrets — don't waste cycles on non-sensitive data
  6. 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)