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
        }
      }
    }
  }
}
SettingDefaultDescription
accessTokenExpiry"15m"Access token lifetime
refreshTokenExpiry"7d"Refresh token lifetime
cookies.name"radiant_auth"Cookie name for access token
cookies.securefalseSet Secure attribute
cookies.httpOnlyfalseSet HttpOnly attribute
cookies.sameSite"lax"SameSite policy (lax, strict, none)
cookies.refreshTokenfalseAlso 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"]
      }
    }
  }
}
SettingDefaultDescription
mfa.enabledfalseEnable MFA endpoints
mfa.issuer"Radiant"Name shown in authenticator app
mfa.backupCodeCount10Number of single-use backup codes generated
mfa.enforceForRoles[]Roles that must enroll MFA before login

MFA Enrollment Flow

  1. 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."
}
  1. 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
  1. 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..."
}
  1. 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
  1. 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:

EndpointAuthDescription
POST /:collection/mfa/enrollBearer tokenGenerate secret + backup codes
POST /:collection/mfa/verifyBearer tokenVerify first TOTP code to complete enrollment
POST /:collection/mfa/loginMFA tokenComplete login with TOTP or backup code
POST /:collection/mfa/disableBearer tokenDisable 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
      }
    }
  }
}
SettingDefaultDescription
minLengthMinimum password length
requireUppercasefalseRequire at least one uppercase letter
requireNumberfalseRequire 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"
SettingDescription
secrets.enabledEnable field-level encryption
secrets.encryptionKeyEnv var name for the key (default: RADIANT_ENCRYPTION_KEY)
RADIANT_ENCRYPTION_KEYEncryption key (min 16 characters, recommended 32+)

How It Works

  • On write (create/update): Fields marked with @encrypt are encrypted using AES-256-GCM with a random 12-byte IV per value. Ciphertext is stored as enc: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
    }
  }
}
SettingDefaultDescription
audit.enabledfalseEnable audit logging
audit.secretDerived from JWT_SECRET via HKDFHMAC signing key
audit.retentionDaysUnlimitedAuto-delete entries older than N days

Each CRUD operation (create, update, delete) is automatically logged with:

FieldDescription
actioncreate, update, or delete
collectionCollection slug
recordIdAffected record ID
userIdUser who triggered the action
requestIdRequest ID for correlation with server logs
metadataOperation data
hmacHMAC-SHA256 of the entry + previous entry's HMAC
prevHmacPrevious 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" }
    }
  }
}
SettingDescription
rateLimit.loginRate limit for POST to /login and /register endpoints
rateLimit.writeRate limit for POST/PUT/PATCH/DELETE on all other endpoints
maxMaximum requests in the window
windowTime 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'"
    }
  }
}
HeaderValue
Content-Security-PolicyConfigurable (default: default-src 'self')
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policygeolocation=(), 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-CSRF
  • X-CSRF-Token
  • X-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

VariableRequiredDescription
JWT_SECRETYes (JWT auth)JWT signing secret
JWT_PREVIOUS_SECRETNoPrevious JWT secret for rotation grace period
RADIANT_ENCRYPTION_KEYYes (encryption)AES-256-GCM encryption key (min 16 chars)
RADIANT_AUDIT_SECRETNoAudit HMAC key (defaults to HKDF from JWT_SECRET)
RATE_LIMIT_MAXNoOverride 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:

StepTarget
Acknowledgement48 hours
Initial assessment5 business days
Fix or mitigation30 days (critical: 7 days)
Public disclosureAfter 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 });
FunctionDescription
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.