How to Encrypt and Store Third-Party API Keys in a Next.js SaaS (AES-256-GCM)

I store encrypted API credentials for six affiliate networks inside an affiliate marketing SaaS I built—ClickBank keys, Digistore24 tokens, BuyGoods payloads, MaxWeb secrets, JVZoo strings, Hotmart blobs—all sitting inside PostgreSQL, all unreadable without a sixty-four-character ENCRYPTION_KEY that never touches Prisma rows. Encrypt API keys Next.js AES-256 with GCM the moment onboarding saves JSON, twelve fresh random IV bytes per encrypt, sixteen-byte authentication tags guarding tampering, zero plaintext blobs on disk—and most StackOverflow snippets still silently reuse IVs until keystream XOR attacks appear. Below is Node’s built-in crypto only: Prisma AffiliateNetworkAccount, src/lib/encryption.ts, gated Server Actions, hourly cron decryption lifetimes capped to volatile RAM.

How to Encrypt and Store Third-Party API Keys in a Next.js SaaS (AES-256-GCM)
AES-256-GCM credential encryption for Next.js—from Prisma schema to encrypt/decrypt functions to Server Actions wiring credentials for six integrations

Why Third-Party API Keys Stored Plaintext Hand Attackers Immediate Commission APIs — Until You Encrypt API Keys Next.js AES-256 Before Postgres Persists Rows

Third-party affiliate tokens stored verbatim give any Postgres intruder scripted access to payouts, SKU listings, and refund surfaces without touching user passwords—a lateral nightmare because these keys already authenticate outbound finance APIs. The affiliate marketing SaaS I built fronts ClickBank, Digistore24, BuyGoods, MaxWeb, JVZoo, Hotmart—all six integrations store JSON blobs totaling zero plaintext fields after transforms land in AffiliateNetworkAccount. Production setup: 64-character ENCRYPTION_KEY, 12-byte IVs, 16-byte auth tags, 6 networks, zero plaintext credentials, $20-60/month AI spend on 10 tools. Platform totals from production: 10 tools, 6 networks, $20-60/month AI spend, 58,641 lines, 4-month solo build.

See also: NextAuth v5 role-based access control and multi-tenant Prisma data isolation.

Threat model operators ignore until incident response calls

Assume backups leak, insiders snapshot tables, ransomware exports dumps. Plaintext credential columns mean instantaneous abuse: adversaries replay vendor APIs, rotate webhooks away from victims, scrape historical ledger rows. Hassan Raza on hassanr.com frames encryption as delaying irreversible payout fraud until responders revoke keys—even imperfect timing beats uncorked affiliate impersonation spanning every tenant simultaneously.

Architectural wager: ciphertext without env keys buys hours, not holiness

I chose symmetrical AES-256-GCM with one global derivation secret because pragmatic early-stage infra still beats naive optimism. Encrypt credentials at rest Node.js side before Prisma persists; decrypt exclusively inside cron workers and narrowly scoped Server Actions; never hydrate Next.js serialized session props with blobs or derived checksums attackers could brute offline without salt rotation policies.

Why AES-256-GCM and Not the Algorithm Your Tutorial Used

AES-256-GCM fuses two-hundred-fifty-six-bit secrecy with Galois Counter authentication so any single flipped ciphertext byte fails tag verification prior to yielding attacker-readable JSON—nothing CBC alone guarantees without extra MAC choreography.

AES-256: sixty-four hex characters decode into thirty-two mandatory bytes

ENCRYPTION_KEY must decode to exactly thirty-two octets aligning with AES block expectations; sixty-four hex glyphs equal two hundred fifty-six entropy bits Governments standardize against exhaustive search with contemporary silicon. Encrypt API keys Next.js AES-256 by validating length during boot via Zod in src/env.ts so misconfigured deployments crash builds instead of silently truncating entropy.

GCM emits a sixteen-byte (one-hundred-twenty-eight-bit) authentication tag alongside ciphertext

Encryption alone hides content; authenticity proves rows survived transport untampered. AES-CBC decrypts mutated buffers into plausible-looking gibberish, inviting subtle logic exploits and padding oracle classes unless you bolt HMAC afterward. Galois mode folds MAC and stream cipher interplay into one audited primitive—critical when tamper detection doubles as alerting signal whenever cron decrypt throws.

Warning

AES-CBC snippets lacking Encrypt-then-MAC remain padding-oracle bait—credential storage tutorials peddling CBC without integrity are incomplete. Prefer AES-GCM so Node’s getAuthTag enforces ciphertext discipline automatically.

The IV Mistake That Breaks AES-GCM Security (And How Most Tutorials Make It)

Reusing identical IV-plus-key tuples in AES-GCM collapses confidentiality because XORing two ciphertexts produced under one keystream cancels encryption noise and leaks plaintext bitwise relationships attackers exploit after observing duplicate headers.

Initialization vectors randomize keystream derivation while staying non-secret

An IV separates logically identical plaintext JSON saved twice into unrelated ciphertext fingerprints—preventing deterministic replay fingerprints across tenants. Galois mode feeds IV bits into GHASH polynomials; duplication annihilates semantic security promises NIST SP 800-38D emphasizes for authenticated encryption workloads.

NIST recommends ninety-six-bit (twelve-byte) IVs—the length crypto.randomBytes(12) returns

I generate fresh IV entropy per AffiliateNetworkAccount write, concatenate hex-encoded IV, sixteen-byte authentication tag hex, ciphertext hex separated by ASCII colons exactly as stored in PostgreSQL VARCHAR fields so decrypt always self-describes operands. Larger IV regimes exist but widen GHASH internals without benefit inside our Next.js SaaS envelopes.

Important

IV secrecy adds zero cryptographic margin—publish it openly beside ciphertext. Defense lives in keyed AES plus authenticated tags, not obscure IV placements that complicate auditing.

The Complete AES-256-GCM encrypt() and decrypt() Implementation

The production surface boils to two deterministic helpers inside src/lib/encryption.ts decrypting hex triples Prisma selects—consumers neither fork algorithms nor mishandle tagging manually.

AffiliateNetworkAccount Prisma schema with privacy rationales baked into comments

/**
 * AffiliateNetworkAccount isolates reversible secrets per authenticated user/network pair.
 *
 * encryptedCredentials stores iv:authTag:ciphertext triples rendered as lowercase hex —
 * sixteen-byte GCM tags + twelve-byte IVs + ciphertext blocks—so one column answers
 * backup, replication, subpoena disclosures without dangling side tables leaking joins.
 *
 * @@unique([userId, network]) prevents duplicate onboarding rows that might otherwise
 * race encryption paths or confuse cron deduplication heuristics.
 *
 * lastSyncAt anchors hourly /api/cron/sync-sales incremental fetches keyed off last success,
 * guaranteeing adapters only reconcile deltas after decrypting JSON in volatile memory.
 *
 * Explicitly omitted from this table: plaintext secrets, KMS pointers for future envelope
 * upgrades, auditing metadata—we add those deliberately when SOC2 scope demands retention.
 */
model AffiliateNetworkAccount {
  id                   String   @id @default(cuid())
  userId               String
  user                 User     @relation(fields: [userId], references: [id])
  network              String   // "clickbank" | "digistore24" | "buygoods" | "maxweb" | "jvzoo" | "hotmart"
  encryptedCredentials String   // AES-256-GCM: iv:authTag:ciphertext (hex-encoded)
  isActive             Boolean  @default(true)
  lastSyncAt           DateTime?
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt

  @@unique([userId, network])
}

encryption.ts powering Node crypto round-trips

// ENCRYPTION_KEY must be 64 hex chars (32 bytes).
// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const ALGORITHM = 'aes-256-gcm'; /** Matches key length after hex decode */

/** NIST recommends 96-bit IVs for GCM—cryptographically isolate every encrypt call */
const IV_LENGTH = 12;

/** AES-GCM default auth tag length in Node.js */
const AUTH_TAG_LENGTH = 16;

function getKeyBuffer(): Buffer {
  const hex = process.env.ENCRYPTION_KEY;
  if (!hex || hex.length !== 64 || !/^[0-9a-f]+$/i.test(hex)) {
    throw new Error('ENCRYPTION_KEY must be exactly 64 lowercase hex chars (256 bits)');
  }
  return Buffer.from(hex, 'hex');
}

/**
 * Encrypt plaintext JSON payloads before persistence.
 *
 * @param plaintext JSON string emitted by onboarding validation
 * @returns iv:authTag:ciphertext lowercase hex triple
 * @throws if ENCRYPTION_KEY missing or plaintext empty
 */
export function encrypt(plaintext: string): string {
  if (!plaintext) {
    throw new Error('encrypt() requires non-empty plaintext');
  }
  const key = getKeyBuffer();
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv(ALGORITHM, key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag(); // must run after final()
  const ivHex = iv.toString('hex');
  const tagHex = authTag.toString('hex');
  const cipherHex = ciphertext.toString('hex');
  return `${ivHex}:${tagHex}:${cipherHex}`;
}

/**
 * Decrypt ciphertext produced by encrypt().
 *
 * @param combined colon-delimited hex triple emitted by Postgres
 * @returns UTF-8 JSON string usable by adapters
 * @throws when segments malformed or auth verification fails—treat as security incident
 */
export function decrypt(combined: string): string {
  const [ivHex, tagHex, cipherHex] = combined.split(':');
  if (!ivHex || !tagHex || !cipherHex) {
    throw new Error('Invalid ciphertext envelope');
  }
  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(tagHex, 'hex');
  const ciphertext = Buffer.from(cipherHex, 'hex');

  if (iv.length !== IV_LENGTH || authTag.length !== AUTH_TAG_LENGTH) {
    throw new Error('Unexpected IV/authTag length');
  }

  const decipher = createDecipheriv(ALGORITHM, getKeyBuffer(), iv);
  decipher.setAuthTag(authTag);
  const plainBuf = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return plainBuf.toString('utf8');
}
Tip

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" mints uniformly random sixty-four hexadecimal glyphs—the exact entropy budget AES-256-GCM consumes. Skip human-chosen passwords as raw keys unless you route them through PBKDF2/HKDF with documented parameters.

Wiring Credential Encryption Into the SaaS: Save, Read, and Sync Flows

Encrypt API keys Next.js AES-256 only along three choke points: onboarding Server Actions, hourly sync workers, deliberate rotation scripts—nowhere else—so decrypted JSON never nests inside logs.

Server Action save path with guarded plaintext residency

'use server';

import { z } from 'zod';
import { encrypt } from '@/lib/encryption';
import prisma from '@/lib/prisma';
import { getServerSession } from '@/lib/session';

const NetworkSchema = z.enum([
  'clickbank',
  'digistore24',
  'buygoods',
  'maxweb',
  'jvzoo',
  'hotmart',
]);

const CredentialPayload = z.object({
  networkName: NetworkSchema,
  credentials: z.string().min(4, 'Credentials JSON required'),
});

export type SaveNetworkResult =
  | { ok: true; network: z.infer<typeof NetworkSchema> }
  | { ok: false; error: 'UNAUTHENTICATED' | 'INVALID_INPUT' };

export async function saveAffiliateCredentials(
  rawInput: unknown
): Promise<SaveNetworkResult> {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return { ok: false, error: 'UNAUTHENTICATED' };
  }

  const parsed = CredentialPayload.safeParse(rawInput);
  if (!parsed.success) {
    return { ok: false, error: 'INVALID_INPUT' };
  }

  // Plaintext credentials exist ONLY between validation and encrypt() inside this heap frame.
  const ciphertext = encrypt(parsed.data.credentials);

  await prisma.affiliateNetworkAccount.upsert({
    where: {
      userId_network: {
        userId: session.user.id,
        network: parsed.data.networkName,
      },
    },
    update: { encryptedCredentials: ciphertext, isActive: true },
    create: {
      userId: session.user.id,
      network: parsed.data.networkName,
      encryptedCredentials: ciphertext,
    },
  });

  return { ok: true, network: parsed.data.networkName }; // Never echo ciphertext/JSON
}

Cron read mirrors decrypt-in-memory semantics

Hourly jobs fetch active AffiliateNetworkAccount rows, call decrypt(account.encryptedCredentials), pass parsed objects into adapters, normalize sales, persist AffiliateSale upserts, then let credentials fall out of scope for garbage collection—all without wrapping decrypt output inside console.log templates or Slack webhooks regardless of outage panic. If decrypt throws due to ciphertext corruption, alarms fire before adapters ship unauthorized HTTP clones because authentication tags veto mutated rows immediately.

I further isolate failures: adapter factories never stringify secrets into Axios error payloads, Sentry breadcrumbs scrub affiliate headers, Postgres statement logs exclude dynamic JSON parameters inside prepared statements—we treat every ancillary surface as plaintext-adjacent until proven sanitized. Automated tests assert encrypted strings never appear inside snapshot JSON fixtures.

Client surfaces ignore ciphertext columns entirely

List endpoints deliberately model select: { id: true, network: true, isActive: true, lastSyncAt: true } so Next.js serialization cannot leak ciphertext to React Server Components hydrating dashboards—the same omission discipline I enforced on six-network sales aggregation queries. Hassan Raza parallels that separation with reversible-but-still-sensitive patterns from privacy-safe telemetry writing: distinct payloads, identical instinct about keeping secrets off the wire destined for hydrated React payloads.

Operational rotation remains deliberate: decrypt every row with retiring keys, encrypt with successors, migrate env secrets, rerun smoke syncs—not an impromptu hotfix playbook you improvise amid pager fatigue.

Honestly, symmetric Node crypto executes synchronously—fine for onboarding bursts yet capable of pinning the event loop if you refactor million-row archival jobs overnight; chunked encrypt loops should interleave setImmediate ticks when batching rotations.

Honestly again, singular global entropy simplifies bootstrapping but concentrates blast radius—I note HKDF-derived per-user data keys combined with KMS envelope encryption as the scale-up remediation once compliance demands isolate blast zones per tenant dataset.

AES-256-GCM vs Your Alternatives: An Honest Comparison

AES-256-GCM dominates credential storage ergonomics—it beats plaintext universally, defeats naive CBC ergonomically, preserves tamper proofs, costing only meticulous env hygiene around ENCRYPTION_KEY.

Where honest engineering still hurts

Leak both ciphertext and production env simultaneously and attackers replay affiliate APIs verbatim—encryption raises bar but never replaces revocation drills, anomaly monitors, anomaly budgets. Hassan Raza keeps Next.js secrets management hygiene (no NEXT_PUBLIC_ exposure, audited deploy roles) inseparable from algorithm choice.

Plaintext storage AES-256-CBC AES-256-GCM
Breach impact ❌ All credentials exposed ⚠️ Credentials exposed if IV is static ✅ Credentials protected by key
Tamper detection ❌ None ❌ None without separate MAC ✅ Auth tag throws on any modification
IV required N/A ✅ Yes (often misimplemented) ✅ Yes — must be unique per operation
Authentication built in ❌ No ❌ No ✅ Yes (GCM auth tag)
NIST recommended for credential storage ❌ No ⚠️ Only with Encrypt-then-MAC ✅ Yes
Implementation complexity ✅ None ⚠️ Medium (separate MAC required) ✅ Low (auth built in)
Key management required ❌ No ✅ Yes ✅ Yes — single point of failure
Node.js built-in support ✅ N/A ✅ Yes ✅ Yes (crypto module)

The implementation took 40 lines of TypeScript. The decision — which algorithm, which mode, how to handle the IV, where the key lives — took longer than the code. AES-256-GCM with a random IV per operation is not the most complex encryption scheme available. It's the most correct one for credential storage that a single engineer can implement without making a subtle, silent mistake.

This walkthrough expresses secure engineering—not legal, compliance, or insurance advice; pair these controls with DPIAs counsel approves alongside vendor DPAs regulating subprocessors touching decrypted traffic.

Frequently Asked Questions

Encrypt payloads with AES-256-GCM, persist twelve-byte IVs plus sixteen-byte auth tags plus ciphertext as colon-delimited hex, keep ENCRYPTION_KEY as sixty-four-character secrets outside NEXT_PUBLIC, and hydrate clients strictly with statuses. Hassan Raza publishes this playbook on hassanr.com atop Prisma AffiliateNetworkAccount rows that cover ClickBank, Digistore24, BuyGoods, MaxWeb, JVZoo, Hotmart integrations with zero plaintext at rest—generate entropy via crypto.randomBytes(32).toString('hex'). Never serialize ciphertext downward. ENCRYPTION_KEY is the catastrophic joint: stash it inside Vercel envs, AWS Secrets Manager, Vault, HashiCorp, or Doppler—not repos—or simultaneous DB plus env leaks decrypt everything wholesale.

AES-256-GCM binds a two-hundred-fifty-six-bit symmetric block cipher with Galois Counter Mode emitting a sixteen-byte authentication tag guarding tampering. Hassan Raza uses it anytime affiliate credentials, OAuth tokens, or partner secrets persist while tamper refusal must hard-fail before plaintext returns—AES-CBC without Encrypt-then-MAC quietly decrypts attacker-flipped bits into garbage ciphertext relationships. Prefer GCM for field-level Postgres encryption because authentication ships inside one Node crypto invocation. Twelve-byte ninety-six-bit IVs must stay unique per write—randomBytes(12)—and travel openly beside ciphertext hex. Skip passphrases as raw keys unless PBKDF2 or HKDF stretches them deliberately.

Mature teams encrypt secrets at rest with AES-256-GCM or audited equivalents while isolating root keys outside databases through secrets managers auditing rotation. Hassan Raza’s affiliate marketing SaaS keeps six outbound adapters fed via hourly cron decrypting JSON strictly in volatile memory aligned with envelopes described on hassanr.com. Scale-outs adopt AWS Secrets Manager, HashiCorp Vault, Doppler, or cloud KMS-derived data keys powering per-row envelope encryption separating blast radius beyond one global entropy string. Plaintext Postgres columns vanish entirely—only iv-authTag-ciphertext triples endure. Operational truth: disciplined Node crypto primitives define the baseline; managed secrets layers add audits, revocation, quorum access atop identically sane ciphertext math.