How to store external API keys securely

Tiger AbrodiTiger Abrodi
10 min read

Introduction

I'll start by showing you the full code for how to store and get API keys.

When I built my text to speech platform, I used Convex. But the code I'll show you is more of a generic example that you can take and tweak for your own use case. 🤙

We'll look at the full code and from there learn every bit you need to know in chronological order.

Full code

// Types for clarity
type User = {
  id: string
  api?: {
    encryptedKey: number[]
    initializationVector: number[]
  }
}

const ALGORITHM = { name: 'AES-GCM', length: 256 }

async function getEncryptionKey(encryptionSecret: string) {
  // TextEncoder can be used to convert a string into a Uint8Array
  const encoder = new TextEncoder()

  // Key material is the encryption secret converted to a Uint8Array
  const keyMaterial = encoder.encode(encryptionSecret)

  // Digest the key material using SHA-256 and pass keyMaterial as the input
  const hash = await crypto.subtle.digest('SHA-256', keyMaterial)

  // Turns our hash into CryptoKey which can be used for encryption
  return await crypto.subtle.importKey('raw', hash, ALGORITHM, false, [
    'encrypt',
    'decrypt',
  ])
}

async function storeApiKey(apiKey: string, userId: string, encryptionSecret: string) {
  const key = await getEncryptionKey(encryptionSecret)

  // Returns a Uint8Array of 12 random bytes
  const initializationVector = crypto.getRandomValues(new Uint8Array(12))

  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: initializationVector },
    key,
    new TextEncoder().encode(apiKey)
  )

  // This would be your DATABASE call
  await updateUser(userId, {
    api: {
      // Turn `encrypted` which is a binary object first into a Uint8Array
      // Uint8Array is a typed array that represents an array of 8-bit unsigned integers
      // It's a more efficient way to handle binary data in JavaScript
      // Then turn the Uint8Array into an Array
      // This is what gets stored in the database as encryptedKey
      encryptedKey: Array.from(new Uint8Array(encrypted)),

      // Turn the initialization vector into an Array
      // initializationVector is already a Uint8Array, so we can just convert it to an Array
      initializationVector: Array.from(initializationVector),
    },
  })
}

async function getApiKey(user: User, encryptionSecret: string) {
  if (!user.api) return null

  // encryptionSecret should come from environment variables, e.g.:
  // const encryptionSecret = process.env.ENCRYPTION_SECRET
  // Never hardcode this value or expose it in client-side code
  const key = await getEncryptionKey(encryptionSecret)

  // Decrypt the encrypted data using the key and initialization vector
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(user.api.initializationVector) },
    key,
    new Uint8Array(user.api.encryptedKey)
  )

  // Decode the decrypted data using TextDecoder
  // Converts it into plain text
  return new TextDecoder().decode(decrypted)
}

Why storing API keys securely matters

API keys serve as authentication tokens that let applications access services. When someone obtains your API key, they can access these services while appearing to be you.

Common security mistakes

Plain text storage of API keys is a fundamental security mistake. When API keys are stored in plain text, they're readable by anyone who gains access to your database. Another error is using weak encryption methods that can be easily broken.

High-level overview of our approach: encryption at rest

In the code shown, we're implementing "encryption at rest" - meaning the API key exists in plain text only when actively being used by the application. At all other times, it's stored in an encrypted form.

The encryption process has several key parts:

  • Converting the API key to binary data for encryption

  • Using a strong cryptographic algorithm (AES-GCM) to encrypt this data

  • Generating random initialization vectors to ensure each encryption is unique

  • Storing both the encrypted data and the initialization vector

  • Converting this data into a format suitable for database storage

Binary Data fundamentals

When working with encryption, we deal with binary data: raw sequences of ones and zeros. JavaScript strings and numbers aren't suitable for cryptographic operations, which is why our code uses Uint8Array.

Uint8Array is an array-like object that stores unsigned 8-bit integers (values 0-255). Each number represents a byte. Cryptographic operations work with these raw bytes directly.

The code uses TextEncoder and TextDecoder to convert between strings and binary:

new TextEncoder().encode(apiKey); // string -> Uint8Array
new TextDecoder().decode(decrypted); // Uint8Array -> string

We also convert between regular arrays and Uint8Array:

// Note: `encrypted` here is raw binary data in ArrayBuffer format
Array.from(new Uint8Array(encrypted)); // for database storage
new Uint8Array(user.api.encryptedKey); // when retrieving from database

This conversion is needed because many databases don't store binary data types directly. They store arrays of numbers instead.

Cryptography fundamentals

Hashing is a one-way function that takes input data and produces a fixed-size output (called a hash or digest). Our code uses SHA-256, which always produces a 256-bit (32-byte) output no matter the input size:

const hash = await crypto.subtle.digest("SHA-256", keyMaterial);

An important trait of hashing is that you can't reverse it to get the original input. Even a tiny change in the input produces a completely different hash output (avalanche effect). This property is why we use hashing in our code to derive encryption keys from secret values. So that even if someone gains access to the encrypted data, they can't reverse it to get the original API key.

This is different from encryption, which is two-way. You can encrypt data and then decrypt it back to its original form if you have the right key. Our code uses the hash as key material for encryption.

We always use the same secret value (from environment variables) to create the hash. The same input produces the same hash every time. As mentioned, it's one way and predictable, but you can't reverse it to get the original input. That's why the secret value must be in our environment variables.


Encryption comes in two types: symmetric and asymmetric. Our code uses symmetric encryption (AES-GCM) where the same key is used for both encryption and decryption. If you look up AES on wikipedia, you'll see it's the recommended algorithm for data at rest.

AES-GCM specifically provides three important features:

  • Confidentiality: The encrypted data can't be read without the key

  • Integrity: Any tampering with the encrypted data will be detected

  • Authenticity: Confirms the data was encrypted by someone with access to the key

This is set up in our code:

const ALGORITHM = { name: "AES-GCM", length: 256 };

We use AES with a 256-bit key length (the strongest option) and GCM mode (Galois/Counter Mode). GCM mode is particularly important because it provides the integrity and authenticity checks automatically. If someone tampers with our encrypted API key, the decryption will fail instead of producing corrupted data.

Key Generation and Management

Key generation in our code starts with an encryption secret. This could be an environment variable or securely stored configuration value.

Here's the key generation process:

const ALGORITHM = { name: "AES-GCM", length: 256 };

// encryptionSecret should come from environment variables, e.g.:
// const encryptionSecret = process.env.ENCRYPTION_SECRET
// Never hardcode this value or expose it in client-side code
async function getEncryptionKey(encryptionSecret: string) {
  const encoder = new TextEncoder();
  const keyMaterial = encoder.encode(encryptionSecret);
  const hash = await crypto.subtle.digest("SHA-256", keyMaterial);
  return await crypto.subtle.importKey("raw", hash, ALGORITHM, false, [
    "encrypt",
    "decrypt",
  ]);
}

We first convert the secret to binary data (keyMaterial). Then we hash it, this ensures our encryption key has the exact properties needed for AES-256: it's exactly 256 bits and appears random.

The importKey operation converts this hash into a proper CryptoKey object. The parameters tell us:

  • 'raw' means we're importing plain binary data

  • false means the key can't be exported (can't be extracted from memory)

  • ['encrypt', 'decrypt'] restricts what operations this key can perform

Each time we need to encrypt or decrypt, we regenerate this key from the secret. This means we only need to securely store the encryption secret, not the key itself.

Initialization Vectors (IV)

const initializationVector = crypto.getRandomValues(new Uint8Array(12));

The IV is important for security reasons. It ensures that encrypting the same API key twice produces different encrypted outputs. Without an IV, identical inputs would create identical encrypted outputs, which could reveal patterns to attackers. We store this too. Otherwise we wouldn’t be able to decrypt later.

Our code:

  1. Creates a 12-byte IV (standard size for GCM mode) using crypto.getRandomValues

  2. Uses it in encryption:

await crypto.subtle.encrypt(
  { name: "AES-GCM", iv: initializationVector },
  key,
  new TextEncoder().encode(apiKey)
);
  1. Stores it alongside the encrypted data:
api: {
  encryptedKey: Array.from(new Uint8Array(encrypted)),
  initializationVector: Array.from(initializationVector),
}

We need to store the IV because the exact same one is required for decryption. Each encryption must use a new random IV, never reuse them.

Typically, we'll store the API key once and then always get the same encrypted key and IV. Of course, you can update it if you want to. Then we update the user with the new encrypted key and IV too.

Encryption Process

The encryption process in our code combines all the concepts we've covered.

Let's trace through it:

async function storeApiKey(apiKey: string, userId: string, encryptionSecret: string) {
  // `key` is a CryptoKey object
  const key = await getEncryptionKey(encryptionSecret)

  // `initializationVector` is a Uint8Array of 12 random bytes
  const initializationVector = crypto.getRandomValues(new Uint8Array(12))

  // `encrypted` is a binary object
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: initializationVector },
    key,
    new TextEncoder().encode(apiKey)
  )
  1. We generate our encryption key from the secret

  2. Create a random IV

  3. Convert the API key to binary data with TextEncoder

  4. Encrypt using AES-GCM, which needs three inputs:

    • The configuration (algorithm name and IV)

    • The key we generated

    • The binary data to encrypt

Then we prepare it for storage:

await updateUser(userId, {
  api: {
    // Turn `encrypted` which is a binary object first into a Uint8Array
    // Uint8Array is a typed array that represents an array of 8-bit unsigned integers
    // It's a more efficient way to handle binary data in JavaScript
    // Then turn the Uint8Array into an Array
    // This is what gets stored in the database as encryptedKey
    encryptedKey: Array.from(new Uint8Array(encrypted)),

    // Turn the initialization vector into an Array
    // initializationVector is already a Uint8Array, so we can just convert it to an Array
    initializationVector: Array.from(initializationVector),
  },
});

We store both the encrypted data and its IV, both converted to regular arrays since many databases don't handle binary data directly.

Decryption Process

Here's how the decryption process works:

async function getApiKey(user: User, encryptionSecret: string) {
  if (!user.api) return null;

  // `key` is a CryptoKey object
  const key = await getEncryptionKey(encryptionSecret);

  // `decrypted` is an ArrayBuffer
  // It is an array of bytes
  const decrypted = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: new Uint8Array(user.api.initializationVector) },
    key,
    new Uint8Array(user.api.encryptedKey)
  );

  // Convert the decrypted binary back to a string
  return new TextDecoder().decode(decrypted);
}
  1. First, we check if encrypted data exists at all (does user have an API key?)

  2. Generate the same encryption key using the same secret (from our environment variables)

  3. Convert our stored data back to binary (Uint8Array)

  4. Decrypt using AES-GCM, which needs:

    • The same algorithm name and IV used for encryption

    • The same key

    • The encrypted binary data

  5. Convert the decrypted binary back to a string

If anything was tampered with the encrypted data, the IV, or if a wrong secret was used → the decryption will fail and throw an error.

Security considerations

Keep the encryption secret server-side only. It should never be exposed to clients or logged anywhere. Store it securely in environment variables or a secrets management system.

Database security is still important:

  • Even though the API keys are encrypted, limit database access to only necessary services.

  • Use strong database user passwords.

  • Enable encryption at rest on the database itself if available.

Error handling must be generic (same as when dealing with logging in errors). Never reveal if decryption failed because of a wrong secret versus corrupted data, as this could help attackers. Just return a general "invalid credentials" message.

Consider key rotation:

  • Periodically change the encryption secret.

  • When you do, re-encrypt all API keys with the new secret.

  • Keep track of which secret was used to encrypt each key if you support multiple active secrets during rotation.

Finally, remember the encryption is only as strong as the security of your encryption secret. A compromised secret means all API keys could be decrypted.

0
Subscribe to my newsletter

Read articles from Tiger Abrodi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tiger Abrodi
Tiger Abrodi

Just a guy who loves to write code and watch anime.