# Encrypt a secret you need to read later

> Keep reversible secrets encrypted with an application-owned key and explicit ownership context.

- Package: `@db3.ai/app/security`
- Canonical page: [https://db3.ai/docs/security](https://db3.ai/docs/security)
- Markdown: [https://db3.ai/docs/security.md](https://db3.ai/docs/security.md)
- Framework source of truth: `packages/app/src/security/README.md`

<a id="setup"></a>

## Run 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.

- [Installation](https://db3.ai/docs/installation.md)
- [Password authentication](https://db3.ai/docs/auth.md)

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/security/examples/. examples/
```

<a id="run"></a>

## 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`.

### Run the lab

```bash
npx tsx examples/runSecretRoundTrip.ts
```

<a id="encrypt"></a>

## Encrypt 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.

### examples/runSecretRoundTrip.ts

```typescript
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));
```

<a id="key"></a>

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

- [Key options and provisioning helper](https://db3.ai/docs/security-api.md#key)
- [Environment configuration](https://db3.ai/docs/config.md)

<a id="context"></a>

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

<a id="fields"></a>

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

- [Fields and output policy](https://db3.ai/docs/fields.md)
- [Persisting encrypted values](https://db3.ai/docs/cookbook-secrets.md)

<a id="failures"></a>

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

<a id="testing"></a>

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

### tests/security/runSecretRoundTrip.test.ts

```typescript
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();
	}
});
```

<a id="run-tests"></a>

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

### Run your copied test and check types

```bash
npx vitest run tests/security/runSecretRoundTrip.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage 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.

- [Full Security API](https://db3.ai/docs/security-api.md)

## Behavioural verification
Round-trips an ephemeral secret and rejects tampering, changed context, a different key and non-JSON input.
- Behaviour test: `packages/app/src/security/tests/examples/runSecretRoundTrip.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- security --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Node.js 24. Real authenticated encryption and App; no database, dotenv writes or persistent key changes.

## Related documentation
- [Security API reference](https://db3.ai/docs/security-api.md): Current emitted signatures and options for @db3.ai/app/security.
- [Configuration](https://db3.ai/docs/config.md): Read settings once at boot. Parse environment values, validate the bits your application needs and pass them into the services that use them.
- [Auth](https://db3.ai/docs/auth.md): Give an account one or more login methods. Issue bearer sessions, reset passwords and revoke access without mixing identity with credentials.
- [Store an integration secret safely](https://db3.ai/docs/cookbook-secrets.md): Persist an encrypted model value, load it only in authorized server code and keep it out of public JSON.

## Framework-owned source: `packages/app/src/security/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Security

The security service centralizes reversible application encryption. It reads
its key and cipher from the active app config, then owns the authenticated
cipher, versioned payload format, and JSON serialization used by framework
fields and application services.

Password hashing is intentionally separate. Passwords are verified through a
one-way hash and have no reusable encryption key; values such as webhook secrets
and WordPress application passwords must be decrypted later and therefore use
`app().security`.

## Run a disposable secret round trip

Complete [Installation](https://db3.ai/docs/installation), then run in your independent app:

```sh
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/security/examples/. examples/
npx tsx examples/runSecretRoundTrip.ts
```

Expect `roundTrip`, `randomized`, `contextRejected`, `tamperingRejected` and `nonJsonRejected` all true. The example uses real encryption and a disposable App. It prints no key, plaintext or ciphertext, makes no database connection and does not read or write `.env`.

The key is generated for the lab only. A persistent application must generate a key once during provisioning and retain it; copying the per-run lab key pattern into production would make stored secrets unreadable after restart.

Copy the exact test from the [Security guide](https://db3.ai/docs/security#testing) into `tests/security/runSecretRoundTrip.test.ts` and run:

```sh
npx vitest run tests/security/runSecretRoundTrip.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

The tests check tampering, wrong-owner context and a different key, then prove the original service can still decrypt its value. Wrong-key failures must not silently overwrite ciphertext with an empty secret. These tests do not replace authorization or database backup/restore tests.

## Configuration

Applications should define a `security` config section:

```ts
import { defineConfig, env } from '@db3.ai/app/config';
import { ensureAppKey, type SecurityOptions } from '@db3.ai/app/security';

export default defineConfig({
	key: env.string('APP_KEY') || ensureAppKey(),
	cipher: 'aes-256-gcm',
} satisfies SecurityOptions);
```

`ensureAppKey()` is a convention-based convenience for application config. It
always uses `APP_KEY` and `.env`: an existing non-empty value is preserved, a
missing value is generated and written to `.env`, and a persistence failure
throws `SecurityError` so startup fails. The security service itself does not
read environment variables or write files; `app().config` remains the boundary
between environment configuration and framework services.

The same key must be available to every API and worker process that reads or
writes encrypted data. Back it up separately from the database: losing or
changing the key makes existing ciphertext unreadable. Key rotation is not yet
implemented, so replace the key only as part of a deliberate data re-encryption
operation.

Writable dotenv files are convenient for local and single-host environments.
Production containers commonly use a read-only application filesystem and
inject secrets through their deployment platform. Providing `APP_KEY`
externally bypasses `ensureAppKey()` and no file write is attempted. Replicated
processes must receive one shared key rather than generating separate keys into
ephemeral files.

Keys can also be generated explicitly when provisioning a deployment:

```sh
printf 'base64:%s\n' "$(openssl rand -base64 32)"
```

## Encryption

New payloads use AES-256-GCM with a random 96-bit initialization vector and a
128-bit authentication tag. The stored envelope includes a format version and
cipher name so future readers can distinguish formats. Encryption authenticates
both the ciphertext and optional caller-supplied context.

```ts
const payload = app().security.encryptJson({
	token: 'provider-secret',
}, {
	additionalAuthenticatedData: 'websites:blog_integration',
});

const value = app().security.decryptJson<{ token: string }>(payload, {
	additionalAuthenticatedData: 'websites:blog_integration',
});
```

Never log plaintext, ciphertext, keys, or decrypted provider errors.

Additional authenticated context is not stored in the envelope; reconstruct it identically when decrypting. It authenticates use but does not authorize the caller. `decrypt()` returns a Buffer; `decryptJson<T>()` parses JSON but does not validate the generic type at runtime. The key is pinned for the service lifetime, not reread on each operation.

## Encrypted model fields

Use `field.encryptedJson<T>()` for JSON-compatible secrets stored on an
ActiveRecord model:

```ts
blogIntegration: field.encryptedJson<BlogIntegration>({
	column: 'blog_integration',
	selectedByDefault: false,
})
```

The field stores versioned ciphertext in a long text column. It is always hidden
from `toJSON()`, cannot be populated through request helpers when request
guarded, and cannot be used in equality queries because encryption is
randomized. It also authenticates the model table and database column name so a
payload copied to a different encrypted field will not decrypt.

That automatic field context is table/column identity, not row-level ownership. Keep authorization and any row/tenant binding policy explicit.

For fields with `selectedByDefault: false`, integration-specific server code
must opt in explicitly:

```ts
const website = await Website
	.query()
	.withField('blogIntegration')
	.wherePk(websiteId)
	.firstOrFail();
```

See [all current Security contracts](https://db3.ai/docs/security-api) for key, cipher, payload and error options. Automatic key rotation, KMS integration and searchable encryption are not implemented. Keep a re-encryption/backup plan separate from this local happy path.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/security` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
