Encryption

Use @beignet/core/encryption to protect strings your server must recover later, such as integration tokens or sensitive stored payloads. The built-in implementation uses Web Crypto AES-256-GCM to encrypt and authenticate each value. Changing the encrypted value, using the wrong key, or supplying the wrong authenticated context causes decryption to fail.

Generate and configure a key

Generate a key once for each environment:

bun beignet encryption key

The command prints a base64:-prefixed key containing 32 random bytes. It does not read or update environment files. Copy the result into your host's server secret store as ENCRYPTION_KEY; keep terminal output out of shared logs. --json returns { "schemaVersion": 1, "key": "..." } for secure automation. You can also call generateEncryptionKey() from server code.

Merge these fields into the server section of lib/env.ts:

import { createEnv } from "@beignet/core/config";
import { z } from "zod";

export const env = createEnv({
  server: {
    ENCRYPTION_KEY: z.string().min(1),
    ENCRYPTION_PREVIOUS_KEYS: z.string().default("").transform((value) =>
      value === "" ? [] : value.split(",").map((key) => key.trim()),
    ),
  },
  runtimeEnv: process.env,
});

createEncryption(...) validates every key synchronously. Missing, malformed, or incorrectly sized keys throw TypeError without including their values. It does not generate a fallback key or read environment variables itself. Never generate a replacement on startup: a restart would make stored data unreadable. Keep encryption keys separate from authentication and signing secrets.

Wire the port

Add EncryptionPort to your completed AppPorts type in ports/index.ts:

import type { EncryptionPort } from "@beignet/core/encryption";

export type AppPorts = {
  encryption: EncryptionPort;
};

Bind the implementation alongside your other ports in infra/port-wiring.ts:

import { createEncryption } from "@beignet/core/encryption";
import { definePorts } from "@beignet/core/ports";
import { env } from "@/lib/env";
import type { AppPorts } from "@/ports";

export const initialPorts = definePorts<AppPorts>()({
  bound: {
    encryption: createEncryption({
      key: env.ENCRYPTION_KEY,
      previousKeys: env.ENCRYPTION_PREVIOUS_KEYS,
    }),
  },
  deferred: [],
});

Use cases can call ctx.ports.encryption. When encryption is a storage detail, inject the port into the repository adapter and encrypt immediately before writing, then decrypt when reading. Contracts and shared schemas describe the application's inputs and outputs; they do not select keys or encrypt data. Keep authorization in the use case and tenant scoping in the repository.

The implementation is server-only. beignet lint rejects value imports from client-reachable code, contracts, domain code, and use cases; type-only port imports are allowed. Instantiate it in infra and access the port from workflows.

Encrypt and decrypt strings

Both methods take an options object and return Promise<string>. This helper can live in an integration's repository adapter:

import type { EncryptionPort } from "@beignet/core/encryption";

export async function encryptAccessToken(options: {
  encryption: EncryptionPort;
  tenantId: string;
  integrationId: string;
  accessToken: string;
}) {
  return options.encryption.encrypt({
    value: options.accessToken,
    context: {
      purpose: "integrations.access-token",
      tenantId: options.tenantId,
      integrationId: options.integrationId,
    },
  });
}

export async function decryptAccessToken(options: {
  encryption: EncryptionPort;
  tenantId: string;
  integrationId: string;
  encryptedToken: string;
}) {
  return options.encryption.decrypt({
    value: options.encryptedToken,
    context: {
      purpose: "integrations.access-token",
      tenantId: options.tenantId,
      integrationId: options.integrationId,
    },
  });
}

context is optional authenticated metadata: a plain record of strings whose key order does not matter. It is not included in the encrypted value. Supply the same expected context on reads, using the authorized tenant and intended record. Missing or different fields cause decryption to fail. Keep context stable across reads; renaming a purpose or moving a record to another tenant requires decrypting with the old context and encrypting with the new one. Context binding does not grant permission to read a record.

Store the complete returned string in a sufficiently large text column. Each encryption uses a fresh random IV, so encrypting the same string twice produces different values. Do not query or create uniqueness constraints on encrypted values. Serialize structured data explicitly with JSON.stringify(...) and validate the parsed result against its schema after decryption.

The versioned value includes its IV, ciphertext, and authentication tag; treat it as opaque. The built-in format is not compatible with Laravel ciphertext. It handles values in memory and is intended for application fields, not streaming large files. It runs on Beignet's supported Node.js and Bun runtimes using standard Web Crypto APIs.

Handle decryption failures

Malformed values, unsupported versions, tampering, wrong keys, and context mismatches reject with EncryptionDecryptionError. Its message is always Unable to decrypt encrypted value. and contains no input or underlying error.

Treat the failure as unreadable data. Never fall back to returning the stored string as plaintext or overwrite the record with an empty value. Let an unexpected failure reach your normal server error handling. If recovery is a product workflow, translate it into a declared app catalog error inside the adapter, then offer reauthorization or recovery through an authorized use case. Keep diagnostics to safe record IDs and error codes.

Rotate keys

key always encrypts new writes. Decryption tries key, then each previousKeys entry in order. Keeping previous keys does not rewrite existing records or expire old ciphertext.

For a rolling deployment, coordinate web processes, workers, tasks, and any other process that reads encrypted data:

  1. Generate the next key and securely back it up.
  2. Deploy the current key unchanged, with the next key added to ENCRYPTION_PREVIOUS_KEYS. Wait until every reader has this configuration.
  3. Deploy the next key as ENCRYPTION_KEY, keeping the old current key and any still-needed older keys in ENCRYPTION_PREVIOUS_KEYS. New writes now use the next key; readers from the preceding deployment can still read them.
  4. If retiring an old key, run an app-owned task that reads bounded batches, decrypts with the retained keys, and encrypts with the current key. Checkpoint progress and use a transaction or compare-and-swap on the stored ciphertext to avoid overwriting concurrent updates.
  5. Verify the backfill and recovery requirements before removing the old key from active readers. Keep the keys needed to restore retained backups in separately controlled recovery storage.

Rollback configurations must retain every key used by writes since the deployment began. Key compromise requires an incident response plan: rotation cannot protect ciphertext an attacker already obtained with the compromised key. Set rotation and per-key usage limits appropriate to your workload; Beignet does not count encryptions or rotate keys automatically.

Recovery and security boundaries

Back up keys separately from the database, restrict access, and test restoring a database backup with its matching keys. Losing all copies of a required key makes its ciphertext unrecoverable. Restoring only the database is insufficient. Every instance and worker in the same environment needs the appropriate key ring; development and production should use different keys.

Managed secret stores can supply these keys at startup. The built-in implementation holds keys in the application process; it does not integrate with a key management service or keep keys inside a hardware security module. An app-owned EncryptionPort adapter can use an established KMS or envelope encryption library when required. Such an adapter owns its format, authenticated context, failure behavior, migration, and rotation policy; the built-in previousKeys option does not configure a KMS.

This protects selected stored fields when an attacker has the stored values without the keys. It does not protect a compromised application process, replace authorization or TLS, hash passwords, or provide end-to-end encryption. Use your authentication library for password hashing.

Encryption does not automatically protect request bodies, returned values, audit data, queues, caches, or logs. The encryption API emits no telemetry, but other layers can still capture plaintext: configure redaction and retention for every sink. Prefer storing record references in jobs and events, then load sensitive values only where they are needed.

For the underlying security guidance, see the OWASP cryptographic storage guidance and key management guidance.