Security
Radiant includes a comprehensive security subsystem covering authentication, encryption, audit logging, rate limiting, and vulnerability management. This page documents every security feature with configuration examples.
Authentication
Radiant supports three authentication strategies: JWT, API keys, and MFA/TOTP. They can be combined — for example, JWT with MFA enforcement for admin roles.
JWT Authentication
JWT is the default auth strategy. It uses HS256 signing via the jose library with access + refresh token rotation.
config {
security: {
auth: {
strategies: ["jwt"]
jwt: {
accessTokenExpiry: "15m"
refreshTokenExpiry: "7d"
cookies: {
name: "radiant_auth"
secure: true
httpOnly: true
sameSite: "strict"
refreshToken: true
}
}
}
}
}
| Setting | Default | Description |
|---|---|---|
accessTokenExpiry | "15m" | Access token lifetime |
refreshTokenExpiry | "7d" | Refresh token lifetime |
cookies.name | "radiant_auth" | Cookie name for access token |
cookies.secure | false | Set Secure attribute |
cookies.httpOnly | false | Set HttpOnly attribute |
cookies.sameSite | "lax" | SameSite policy (lax, strict, none) |
cookies.refreshToken | false | Also set refresh token cookie |
Refresh tokens are rotated on every use — the old token is revoked and a new pair is issued. Tokens are stored as SHA-256 hashes, never raw.
JWT Secret Rotation
To rotate your JWT secret without invalidating active sessions, set both the new and old secret during a transition period:
# .env
JWT_SECRET="new-secret-value-here"
JWT_PREVIOUS_SECRET="old-secret-value-here"
New tokens are signed with JWT_SECRET. Existing tokens are verified against JWT_SECRET first, then JWT_PREVIOUS_SECRET as fallback. Once all old tokens have expired, remove JWT_PREVIOUS_SECRET.
API Key Authentication
config {
security: {
auth: {
apiKey: {
enabled: true
header: "x-api-key"
}
}
}
}
API keys are stored as SHA-256 hashes in the radiant_api_keys system table. Verification uses timing-safe comparison.
Generate an API key:
bun run gen:key
Example API request with an API key:
curl -H "x-api-key: rak_xxxxxxxx" https://your-app/api/todos
MFA / TOTP
Multi-factor authentication using RFC 6238 TOTP. Compatible with Google Authenticator, Authy, 1Password, and any standard TOTP app.
config {
security: {
auth: {
mfa: {
enabled: true
issuer: "My App"
backupCodeCount: 10
enforceForRoles: ["admin"]
}
}
}
}
| Setting | Default | Description |
|---|---|---|
mfa.enabled | false | Enable MFA endpoints |
mfa.issuer | "Radiant" | Name shown in authenticator app |
mfa.backupCodeCount | 10 | Number of single-use backup codes generated |
mfa.enforceForRoles | [] | Roles that must enroll MFA before login |
MFA Enrollment Flow
- User authenticates and calls the enroll endpoint:
curl -X POST -H "Authorization: Bearer <token>" \
https://your-app/api/users/mfa/enroll
Response includes a secret, otpauthUri (for QR codes), and one-time backupCodes:
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauthUri": "otpauth://totp/My%20App:user%40example.com?secret=...",
"backupCodes": ["a1b2c-3d4e5-f6g7h8", ...],
"message": "Scan the QR code with your authenticator app, then verify with a code to complete enrollment."
}
- User scans the QR code, then verifies with their first TOTP code:
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"mfaCode": "123456"}' \
https://your-app/api/users/mfa/verify
- Future logins return an MFA challenge:
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret"}' \
https://your-app/api/users/login
{
"mfaRequired": true,
"mfaToken": "eyJhbGci..."
}
- Complete the login with a TOTP code or backup code:
# With TOTP code
curl -X POST -H "Content-Type: application/json" \
-d '{"mfaToken":"eyJ...","mfaCode":"123456"}' \
https://your-app/api/users/mfa/login
# With backup code
curl -X POST -H "Content-Type: application/json" \
-d '{"mfaToken":"eyJ...","mfaCode":"skip","backupCode":"a1b2c-3d4e5-f6g7h8"}' \
https://your-app/api/users/mfa/login
- Disable MFA (requires current TOTP code):
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"mfaCode":"123456"}' \
https://your-app/api/users/mfa/disable
MFA endpoints:
| Endpoint | Auth | Description |
|---|---|---|
POST /:collection/mfa/enroll | Bearer token | Generate secret + backup codes |
POST /:collection/mfa/verify | Bearer token | Verify first TOTP code to complete enrollment |
POST /:collection/mfa/login | MFA token | Complete login with TOTP or backup code |
POST /:collection/mfa/disable | Bearer token | Disable MFA (requires current TOTP code) |
Backup codes are single-use, SHA-256 hashed, and consumed on use. Store them safely — they are shown only once during enrollment.
Password Policy
Enforce password strength requirements on registration and password reset:
config {
security: {
auth: {
passwordPolicy: {
minLength: 12
requireUppercase: true
requireNumber: true
}
}
}
}
| Setting | Default | Description |
|---|---|---|
minLength | — | Minimum password length |
requireUppercase | false | Require at least one uppercase letter |
requireNumber | false | Require at least one number |
Account Lockout
Lock accounts after too many failed login attempts:
config {
security: {
auth: {
lockout: {
maxAttempts: 5
durationMinutes: 15
}
}
}
}
After maxAttempts failed logins, the account is locked for durationMinutes. Failed attempts are tracked per collection:email and cleared on successful login.
Encryption at Rest
Radiant supports AES-256-GCM field-level encryption. Mark any field with the @encrypt decorator and the value is automatically encrypted before storage and decrypted on read — transparent to your application code.
Schema Example
collection patients {
fields {
name: text
ssn: text @encrypt
diagnosis: text @encrypt
ssnHash: text // deterministic hash for lookups
}
}
Configuration
config {
security: {
secrets: {
enabled: true
}
}
}
Set the encryption key via environment variable:
export RADIANT_ENCRYPTION_KEY="your-32-character-minimum-key"
| Setting | Description |
|---|---|
secrets.enabled | Enable field-level encryption |
secrets.encryptionKey | Env var name for the key (default: RADIANT_ENCRYPTION_KEY) |
RADIANT_ENCRYPTION_KEY | Encryption key (min 16 characters, recommended 32+) |
How It Works
- On write (create/update): Fields marked with
@encryptare encrypted using AES-256-GCM with a random 12-byte IV per value. Ciphertext is stored asenc:v1:<base64(iv)>:<base64(ciphertext)>. - On read (find/findById): Encrypted fields are automatically decrypted before returning to your code.
- Key derivation: The encryption key is derived from your passphrase using HKDF-SHA256 with a fixed label (
radiant-field-encryption-v1), separating it from JWT and audit keys.
Important: Encrypted fields are not queryable — you can't filter by an encrypted column. If you need to look up by an encrypted field, create a deterministic hash column (e.g. ssnHash) alongside it and filter on that instead.
Audit Logging
Radiant's audit log is tamper-evident — each entry is HMAC-SHA256 chained to the previous entry, making silent modification detectable.
config {
security: {
audit: {
enabled: true
retentionDays: 365
}
}
}
| Setting | Default | Description |
|---|---|---|
audit.enabled | false | Enable audit logging |
audit.secret | Derived from JWT_SECRET via HKDF | HMAC signing key |
audit.retentionDays | Unlimited | Auto-delete entries older than N days |
Each CRUD operation (create, update, delete) is automatically logged with:
| Field | Description |
|---|---|
action | create, update, or delete |
collection | Collection slug |
recordId | Affected record ID |
userId | User who triggered the action |
requestId | Request ID for correlation with server logs |
metadata | Operation data |
hmac | HMAC-SHA256 of the entry + previous entry's HMAC |
prevHmac | Previous entry's HMAC (chain link) |
Audit Export
Export all audit entries with chain verification:
curl -H "Authorization: Bearer <token>" \
https://your-app/api/audit/export
Response:
{
"exportedAt": "2026-07-06T12:00:00.000Z",
"totalEntries": 42,
"chainStatus": { "ok": true, "firstBadIndex": null },
"entries": [...]
}
If chainStatus.ok is false, firstBadIndex indicates the first tampered entry.
Manual Retention Cleanup
Trigger audit cleanup programmatically (in addition to the automatic 24h timer):
const deleted = await runtime.purgeOldAuditEntries(90); // delete entries older than 90 days
console.log(`Deleted ${deleted} old audit entries`);
Rate Limiting
config {
security: {
rateLimit: {
login: { max: 5, window: "15m" }
write: { max: 100, window: "1m" }
}
}
}
| Setting | Description |
|---|---|
rateLimit.login | Rate limit for POST to /login and /register endpoints |
rateLimit.write | Rate limit for POST/PUT/PATCH/DELETE on all other endpoints |
max | Maximum requests in the window |
window | Time window: "15m", "1h", "30s" |
Rate limiting uses the configured KV store (memory, SQLite, or Redis) for distributed deployments. Client IP resolution respects trusted proxies.
Override the rate limit max via environment variable (for high-throughput deployments):
export RATE_LIMIT_MAX=1000
Security Headers
config {
security: {
headers: {
enabled: true
contentSecurityPolicy: "default-src 'self'; script-src 'self'"
}
}
}
| Header | Value |
|---|---|
Content-Security-Policy | Configurable (default: default-src 'self') |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | geolocation=(), microphone=(), camera=() |
Set contentSecurityPolicy: false to explicitly omit the CSP header. Set it to a custom string for fine-grained control:
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
CSRF Protection
CSRF protection is built-in and always active for state-changing requests (POST, PUT, PATCH, DELETE) when cookies are present. It uses same-origin verification via Origin/Referer headers, with a custom header bypass for API clients.
config {
security: {
csrfTrustedOrigins: ["https://your-frontend.com"]
}
}
Requests with any of these headers bypass the origin check (for API clients that don't send cookies):
X-RADIANT-CSRFX-CSRF-TokenX-Requested-With
Trusted Proxies
When behind a load balancer or reverse proxy, configure trusted proxy IPs so the rate limiter can safely use X-Forwarded-For:
config {
core: {
api: {
trustedProxies: ["10.0.0.1", "10.0.0.2"]
}
}
}
When trustedProxies is set, X-Forwarded-For is only trusted if the direct connection IP is in the list. Otherwise, the direct connection IP is used.
Secrets Management
Radiant abstracts secret resolution through a SecretsProvider interface, allowing production deployments to use KMS, HashiCorp Vault, or other external secret stores instead of environment variables.
Default: Environment Variables
The default EnvVarSecrets provider reads from process.env. No configuration needed — this is how JWT_SECRET, RADIANT_ENCRYPTION_KEY, and RADIANT_AUDIT_SECRET are resolved today.
Custom Provider
Implement the SecretsProvider interface for external secret stores:
import { type SecretsProvider } from "@codesordinatestudio/radiant-bun";
class VaultSecrets implements SecretsProvider {
async getSecret(name: string): Promise<string | null> {
// Read from HashiCorp Vault
const response = await fetch(`http://vault:8200/v1/secret/data/${name}`, {
headers: { "X-Vault-Token": process.env.VAULT_TOKEN! },
});
if (!response.ok) return null;
const data = await response.json();
return data.data.data.value;
}
async listSecrets(): Promise<string[]> {
// List available secrets
return ["JWT_SECRET", "RADIANT_ENCRYPTION_KEY"];
}
}
Environment Variables Reference
| Variable | Required | Description |
|---|---|---|
JWT_SECRET | Yes (JWT auth) | JWT signing secret |
JWT_PREVIOUS_SECRET | No | Previous JWT secret for rotation grace period |
RADIANT_ENCRYPTION_KEY | Yes (encryption) | AES-256-GCM encryption key (min 16 chars) |
RADIANT_AUDIT_SECRET | No | Audit HMAC key (defaults to HKDF from JWT_SECRET) |
RATE_LIMIT_MAX | No | Override rate limit max threshold |
SBOM & Dependency Auditing
Generate a Software Bill of Materials (CycloneDX 1.5 JSON):
bun run sbom
Output: sbom/radiant-sbom-<timestamp>-cyclonedx.json + sbom/radiant-sbom-latest-cyclonedx.json. Contains all workspace packages and external dependencies with Package URLs (pkg:npm/name@version).
Run a dependency vulnerability audit:
bun run audit
Exits non-zero if any high or critical vulnerabilities are found. Suitable for CI pipelines.
CI Integration
Radiant includes a GitHub Actions workflow (.github/workflows/security.yml) that runs on every push to main, every PR, and weekly on Monday:
- Runs
bun run audit— fails on high/critical CVEs - Runs
bun run sbom— generates SBOM artifact - Uploads SBOM as a GitHub artifact (90-day retention)
Dependabot is configured (.github/dependabot.yml) for weekly npm and GitHub Actions dependency updates.
Vulnerability Disclosure
See SECURITY.md for the full vulnerability disclosure policy.
Summary:
| Step | Target |
|---|---|
| Acknowledgement | 48 hours |
| Initial assessment | 5 business days |
| Fix or mitigation | 30 days (critical: 7 days) |
| Public disclosure | After fix, or 90 days (whichever comes first) |
Do NOT open public GitHub issues for security vulnerabilities. Use GitHub Security Advisories or email security@codesordinate.com.
Webhook Signing
When sending outbound webhooks (e.g. notifying a third-party of events), sign the payload so consumers can verify authenticity.
import { signWebhook, WEBHOOK_SIGNATURE_HEADER } from "@codesordinatestudio/radiant-bun";
const payload = JSON.stringify({ event: "order.created", id: "123" });
const signature = await signWebhook(process.env.WEBHOOK_SECRET!, payload);
// Send with the webhook request
fetch("https://partner.com/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signature,
},
body: payload,
});
The signature header uses the format X-Radiant-Signature: t=<timestamp>,v1=<hex-hmac>.
Consumers verify the signature:
import { verifyWebhookSignature } from "@codesordinatestudio/radiant-bun";
const valid = await verifyWebhookSignature(
process.env.WEBHOOK_SECRET!,
request.headers.get("X-Radiant-Signature")!,
rawBody,
300, // 5 min tolerance
);
if (!valid) return new Response("Invalid signature", { status: 401 });
| Function | Description |
|---|---|
signWebhook(secret, payload) | Signs a payload, returns t=<ts>,v1=<hex> |
verifyWebhookSignature(secret, header, payload, tolerance) | Verifies with constant-time comparison (default 5 min tolerance) |
WEBHOOK_SIGNATURE_HEADER | "X-Radiant-Signature" |
Uses HMAC-SHA256 via Web Crypto. The timestamp prevents replay attacks — signatures older than the tolerance window are rejected.
Idempotency Keys
Prevent duplicate side effects when clients retry requests. Send an Idempotency-Key header on any POST, PUT, or PATCH request:
curl -X POST -H "Content-Type: application/json" \
-H "Idempotency-Key: client-generated-uuid" \
-d '{"amount": 100, "currency": "USD"}' \
https://your-app/api/payments
The first request executes normally and the response is cached in the configured KV store (24-hour TTL). If the same Idempotency-Key is sent again, the cached response is returned with an X-Idempotent-Replay: true header — no duplicate side effect.
# Duplicate request — returns the original response
curl -X POST -H "Content-Type: application/json" \
-H "Idempotency-Key: client-generated-uuid" \
-d '{"amount": 100, "currency": "USD"}' \
https://your-app/api/payments
# Response headers:
# X-Idempotent-Replay: true
Only successful responses (2xx) are cached. Error responses are not cached, allowing the client to retry with the same key after a failure.
Idempotency uses the same KV store as rate limiting (memory, SQLite, or Redis) — in multi-instance deployments, configure Redis-backed KV so idempotency works across instances.