Encrypt a secret you need to read later
Keep reversible secrets encrypted with an application-owned key and explicit ownership context.
On this page
Source-backed MarkdownRun an isolated encryption lab
Complete Installation and copy the example. It generates an ephemeral key only for a disposable App. It does not read/write .env, connect to SQL or modify an existing app key.
Passwords need one-way hashing through Auth, not reversible encryption. Use Security for values the server genuinely needs to recover, such as an integration credential.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/security/examples/. examples/Run and check integrity
Expect five true values: roundTrip, randomized, contextRejected, tamperingRejected and nonJsonRejected. No key, plaintext or ciphertext is printed.
The example encrypts twice, checks the recovered value, then deliberately changes owner context and one encrypted byte. Both decryption attempts fail safely. The App is closed in finally.
npx tsx examples/runSecretRoundTrip.tsEncrypt bytes, text or JSON
encrypt() returns a versioned text envelope; decrypt() returns a Buffer. encryptJson() and decryptJson<T>() wrap JSON serialization/parsing. The generic type does not validate the decrypted object schema.
New payloads use AES-256-GCM with random IVs and authentication tags. The same input gives different ciphertext, so encrypted values are not suitable for equality lookups.
import { Buffer } from 'node:buffer';
import { pathToFileURL } from 'node:url';
import { Security, SecurityError } from '@db3.ai/app/security';
import { App } from '@db3.ai/app/server';
/**
* Encrypts an ephemeral example secret and proves rejection of altered context/data.
*
* Keys are generated only for this disposable lab. No dotenv file is changed and
* no key, plaintext or ciphertext is returned. Persistent apps must retain one key.
*
* @returns Non-sensitive round-trip and integrity-check outcomes.
*/
export async function runSecretRoundTrip() {
const application = new App({ config: { security: { key: Security.generateKey() } }, dbOptions: { syncColumns: false } });
try {
const security = application.security;
const context = { additionalAuthenticatedData: 'notes:ada:integration' };
const secret = { token: 'example-secret-not-for-output' };
const payload = security.encryptJson(secret, context);
const second = security.encryptJson(secret, context);
const decrypted = security.decryptJson<typeof secret>(payload, context);
const parts = payload.split(':');
const encryptedBytes = Buffer.from(parts[5]!, 'base64url');
encryptedBytes[0] = encryptedBytes[0]! ^ 1;
parts[5] = encryptedBytes.toString('base64url');
return {
roundTrip: decrypted.token === secret.token,
randomized: payload !== second,
contextRejected: rejectsSecurity(() => security.decryptJson(payload, { additionalAuthenticatedData: 'notes:grace:integration' })),
tamperingRejected: rejectsSecurity(() => security.decryptJson(parts.join(':'), context)),
nonJsonRejected: rejectsSecurity(() => security.encryptJson(undefined)),
};
} finally { await application.close(); }
}
/** Returns true only for the expected safe framework security error. */
function rejectsSecurity(operation: () => unknown): boolean {
try { operation(); return false; } catch (error) { if (error instanceof SecurityError) return true; throw error; }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runSecretRoundTrip(), null, 2));
Provision one persistent application key
In a real app, load APP_KEY server-side and pass config: { security: { key, cipher: "aes-256-gcm" } } when creating App. Security resolves through the active App; it does not accept arbitrary constructor key options.
Security.generateKey() creates a base64:-prefixed 32-byte key. Generate it once during provisioning, store it securely, and supply the same value to API and worker processes. Never copy the ephemeral-key-per-run lab pattern into an app that stores ciphertext.
ensureAppKey() reads APP_KEY or the working directory’s .env, generates a missing key and writes it to .env. It preserves existing nonempty values for normal validation and fails if persistence fails. Use it only where that file write is intended; injected production secrets avoid the write.
Bind ciphertext to its intended use
additionalAuthenticatedData authenticates a stable context such as owner/field identity. It is not stored in the envelope, so reconstruct exactly the same context when decrypting.
Different context fails authentication, but this is not a substitute for access control. Authorize the caller before loading/decrypting a secret. Plan migrations carefully if an identifier used in that context will change.
Use an encrypted model field
field.encryptedJson() gives the model reusable storage conversion and hidden JSON output. Its field context binds payloads to table/column; it does not automatically add row-owner binding.
With selectedByDefault: false, opt in through query().withField("integration") only in server code that needs the value. Hidden output and request-fillable/guarded policy remain separate protections.
Handle unreadable data without destroying it
Catch SecurityError at a safe application boundary. Wrong key, changed context, malformed data and failed integrity checks must not become a silent empty/default secret or an automatic overwrite.
Back up keys separately from the database and test restore. Losing the key loses access to encrypted data. Automatic key rotation/keyrings are not implemented; a deliberate re-encryption migration needs both old and new keys.
Key material is pinned for the service lifetime. Changing environment/config or constructing another App does not rotate an existing Security instance. Do not log keys, ciphertext, plaintext or raw provider errors containing decrypted credentials.
Testing
Create a tests/security directory and save the test below as runSecretRoundTrip.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { expect, it } from 'vitest';
import { Security, SecurityError } from '@db3.ai/app/security';
import { App } from '@db3.ai/app/server';
import { runSecretRoundTrip } from '../../examples/runSecretRoundTrip';
it('round trips a secret without exposing it and rejects tampering, changed context and non-JSON values', async () => {
const output = await runSecretRoundTrip();
expect(output).toEqual({ roundTrip: true, randomized: true, contextRejected: true, tamperingRejected: true, nonJsonRejected: true });
expect(JSON.stringify(output)).not.toContain('example-secret');
});
it('rejects a different key and recovers with the original service key', async () => {
const original = new App({ config: { security: { key: Security.generateKey() } }, dbOptions: { syncColumns: false } });
const security = original.security;
const encrypted = security.encrypt('test-secret');
const different = new App({ config: { security: { key: Security.generateKey() } }, dbOptions: { syncColumns: false } });
try {
expect(() => different.security.decrypt(encrypted)).toThrow(SecurityError);
expect(security.decrypt(encrypted).toString()).toBe('test-secret');
} finally {
await different.close();
await original.close();
}
});
Test failures as well as round trips
The copied tests use real encryption, check all five lab outcomes, reject another App’s key and prove the original service still decrypts its ciphertext. They use only disposable keys.
Add application tests for authorization, field visibility and backup/restore. This isolated lab is not proof of a database migration or a production key recovery.
npx vitest run tests/security/runSecretRoundTrip.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage and limits
Taught/tested: JSON/text round trip, randomized ciphertext, tampering/context/wrong-key rejection, original-key recovery, non-JSON rejection and secret-safe output. Provisioning persistence, malformed payloads and key length validation also have service tests.
Key rotation, KMS integration, row-level authorization, searchable encryption and secure backup policy are not built-in features. All public Security and payload contracts are linked below.
Round-trips an ephemeral secret and rejects tampering, changed context, a different key and non-JSON input.
The guide test passes against the real framework components.packages/app/src/security/tests/examples/runSecretRoundTrip.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Node.js 24. Real authenticated encryption and App; no database, dotenv writes or persistent key changes.