Skip to content

Security Checklist

A practical hardening pass for a production Ion Drive deployment. Every item below maps to something that actually exists in the code — work through it top to bottom before exposing a server to the internet.

ION_REQUIRE_AUTH defaults to false — out of the box, every data, schema, and admin endpoint is open. That is a deliberate local-development default and must be flipped in production:

Terminal window
ION_REQUIRE_AUTH=true

With it on, RBAC is enforced across REST, GraphQL, MCP, schema, and admin routes. A few endpoints stay public by design: /health, /api/v1 (the endpoint index), /api/v1/openapi.json, and /api/auth/*. Note that the OpenAPI spec reveals your schema’s shape — if that matters to you, keep the server off the public internet or behind your own gateway.

Generate real values for both keys:

Terminal window
# 32-byte hex — used for AES-256-GCM secret encryption
openssl rand -hex 32 # → ION_ENCRYPTION_KEY
# or: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
openssl rand -hex 32 # → ION_AUTH_SECRET (signs sessions/tokens)

With NODE_ENV=production the server refuses to boot unless at least one of them is set; in development it falls back to a fixed, publicly-known dev key (and warns). Use two distinct values, store them in a secret manager, and back up ION_ENCRYPTION_KEY with your database — without it, stored secrets are unrecoverable (see Backup & Restore).

Beyond the boot guard above, production mode:

  • enables helmet’s Content-Security-Policy (the other helmet headers are always on), and
  • disables GraphiQL — the playground at /api/v1/graphql is served whenever NODE_ENV is not production, regardless of auth settings. (Even with RBAC on, the playground page loads over GET by design; the actual GraphQL operations are POSTs and are guarded.)

The container speaks plain HTTP. Put a reverse proxy or ingress (nginx/Caddy/Traefik/cloud LB) in front for TLS, and set ION_PUBLIC_URL to the public HTTPS URL so the auth provider issues correct base URLs.

The default ION_CORS_ORIGINS is allow-all (true) with credentials enabled — any website could make authenticated requests on behalf of a logged-in user’s browser. Pin it to your frontend’s origin:

Terminal window
ION_CORS_ORIGINS=https://app.example.com

(The env var takes a single origin string; multiple origins require programmatic configuration via createServer.)

Per-IP rate limiting is on by default (ION_RATE_LIMIT_ENABLED=true): a global bucket of 300 requests/minute plus a stricter 20 requests/minute bucket for /api/auth/* (login brute-force protection). Adjust for your traffic:

Terminal window
ION_RATE_LIMIT_MAX=300 # global bucket per IP per window
ION_RATE_LIMIT_AUTH_MAX=20 # /api/auth/* bucket per IP per window
ION_RATE_LIMIT_WINDOW_MS=60000

Two caveats: /health and /metrics are exempt (probes and scrapers), and the limiter keys on request.ip. Behind a reverse proxy, set ION_TRUST_PROXY so request.ip reflects the real client instead of the proxy (otherwise every client shares one bucket):

Terminal window
ION_TRUST_PROXY=true # trust X-Forwarded-For from any upstream
ION_TRUST_PROXY=1 # or: trust exactly one proxy hop
ION_TRUST_PROXY=10.0.0.0/8 # or: trust only these proxy addresses/CIDRs

Only enable it when a proxy you control is the sole way to reach the server — trusting forwarded headers from arbitrary clients lets them spoof their IP and rotate rate-limit buckets.

When ION_METRICS_ENABLED=true (the default), GET /metrics serves Prometheus text with no rate limiting and — by default — no authentication. It leaks operational detail (routes, error rates, task names). Either keep it network-internal (cluster-only, a firewall rule, or a proxy block), require a bearer token, or disable it with ION_METRICS_ENABLED=false if you don’t scrape it:

Terminal window
ION_METRICS_TOKEN=$(openssl rand -hex 32)

With the token set, scrapes must send Authorization: Bearer <token> — in Prometheus, via the scrape config’s authorization block:

scrape_configs:
- job_name: ion-drive
authorization:
credentials: <token>

API keys (iond_…) are hashed with SHA-256 at rest — the plaintext is shown once at creation and never stored, so a leaked database doesn’t leak usable keys. There are no per-key scopes; a key’s permissions come entirely from the user and/or role it is bound to. So:

  • bind every key to the least-privileged role that works (e.g. viewer for read-only integrations) — never leave an admin-bound key lying around;
  • set an expiry (expiresAt) on keys for short-lived integrations;
  • rotate by creating a new key and revoking the old one via the admin console’s API Keys page or DELETE /api/v1/api-keys/:id.

9. Review roles and the first-admin bootstrap

Section titled “9. Review roles and the first-admin bootstrap”

Three roles are seeded: admin (everything), editor, and viewer. Two things to internalize:

  • The first user to sign up becomes admin. Sign up yourself immediately after first boot, before the server is reachable by anyone else.

  • Signup stays open by default after that. Later signups receive no roles — with ION_REQUIRE_AUTH=true they can authenticate but do nothing — but they can still create accounts. Set ION_DISABLE_SIGNUP=true to close public registration once the first admin exists (/api/auth/sign-up/* then returns 403; the first-boot signup still works, so it is safe to set from day one):

    Terminal window
    ION_DISABLE_SIGNUP=true

Audit role permissions (/api/v1/roles or the admin console) after installing building blocks — blocks may seed roles of their own.

Everything — data, users, secrets, API-key hashes — is in one database.

  • Never expose Postgres to the public internet; private network/VPC only.
  • Use a strong, unique password (not the dev ion:ion) and a dedicated user.
  • Add ?sslmode=require to ION_DATABASE_URL when the path to the database isn’t a private network.

Every optional subsystem has a flag (all default on):

Terminal window
ION_TASKS_ENABLED=false # scheduled-task engine
ION_BLOCKS_ENABLED=false # /api/v1/blocks install surface
ION_EVENTS_ENABLED=false # message bus + CRUD change events
ION_METRICS_ENABLED=false # /metrics endpoint

Smaller surface, fewer surprises. (REST/GraphQL/MCP are the core product and have no off switch; they are covered by RBAC.)

Block installs via ion-drive add are digest-verified (sha256 over the exact artifact bytes, no --force override), and every install records its provenance (digest, source registry/URL, publisher, trust tier) in the _ion_blocks ledger — so “which servers installed the bad artifact?” is answerable with GET /api/v1/blocks or ion-drive block verify --against-installed. See Building blocks — integrity and trust.

Found something? Don’t open a public issue. Email jared@ionshiftlabs.com per SECURITY.md — acknowledgement within 3 business days, assessment within 14.