galena
Operations

Security model

Trust boundaries, how secrets are kept, and what protects each way in.

Ways in

Ways in Visitors and members reach Galena only through CloudFront, which adds a secret header the API checks; a direct call to the API is refused; the API and the workers reach Aurora only through the Data API with IAM. Internet CloudFront Home region trigger.dev /public/* refused, 403 Data API Visitor reads files Member signed in Direct call no secret Status page adds the secret Dashboard adds the secret API checks it Aurora isolated subnets Workers IAM user
EntryWhoProtected by
Status pageAnyoneStatic files only. HSTS, X-Frame-Options: DENY, a content security policy with hashed scripts
/public/* on the page's domainAnyoneSubscribe, confirm and unsubscribe only; rate limits; answers that never reveal whether an address is subscribed
Dashboard and /v1/*MembersSession cookies from Better Auth, role checks on every route, a content security policy, CloudFront in front
/auth/*AnyoneBetter Auth: password hashing, TOTP two-factor, rate limits stored in the database
API Gateway directlyNobodyRefused unless a secret header that only CloudFront adds is present
AWSThe deploy role, the worker user, the LambdasIAM, each scoped to exactly what it needs

The origin secret

The API Lambda sits behind API Gateway, which has a public address of its own. CloudFront adds a random header, x-galena-origin, to every request it forwards; the value lives in Secrets Manager and the API compares it in constant time. Anything that skips CloudFront, including attempts to forge the visitor's address, is refused with 403. Only /health answers without it.

Rate limits

Better Auth limits sign-in and other auth requests per visitor address, with its counters in the database so every Lambda container shares them. The address comes from CloudFront's CloudFront-Viewer-Address header, which a visitor can't set, and reaches the API in a header the API sets itself. The subscribe form has its own limits; see Limits.

Sign-up

Only first-run setup creates an account. After the workspace exists, every attempt to create a user is refused in a database hook, whatever route it comes through.

Outbound requests

Every outbound URL (monitor checks, Slack, webhooks, the canary) passes the SSRF guard:

  • only http:// and https://, and no credentials in the URL;
  • the host's addresses are checked before the request, then again as the socket connects, so a name can't be re-pointed at a private address in between (DNS rebinding);
  • loopback, private, link-local (which includes the instance metadata service), carrier-grade NAT, multicast, documentation and other special-purpose ranges are refused, in IPv4 and IPv6, including IPv4 addresses embedded in IPv6 ones;
  • every redirect hop is checked again.

Secrets and keys

SecretWhereUsed for
Auth secretSSM SecureStringBetter Auth: signing session cookies and encrypting two-factor secrets
App keySSM SecureString for the API, a trigger.dev secret for the workersSee below
trigger.dev secret keySSM SecureStringThe API and evaluator starting tasks
Database credentialsSecrets Manager, rotated by RDS every 7 daysThe Data API
Origin secretSecrets Manager, replicated to the page regionCloudFront and the API
Worker access keytrigger.dev's environmentThe workers acting in AWS

The app key is 32 random bytes. Galena never uses it directly; it derives a separate key for each purpose with HKDF-SHA256, so no key can stand in for another:

One app key, three derived keys The 32-byte app key is never used directly: HKDF-SHA256 derives a seal key for stored credentials, a link key for confirm and unsubscribe links, and a hash key for visitor addresses. App key 32 bytes, HKDF-SHA256 seal AES-256-GCM link HMAC-SHA256 hash HMAC-SHA256 Slack URLs, webhook secrets stored sealed Confirm and unsubscribe links nothing stored Subscribe form addresses rate limits
Derived keyProtects
sealSlack webhook URLs and webhook signing secrets, encrypted with AES-256-GCM before they are stored. The API never returns them.
linkConfirm and unsubscribe links: an HMAC-SHA256 over the purpose, the subscriber and the time. Nothing about a link is stored.
hashThe addresses of visitors who use the subscribe form, kept only as keyed hashes for its rate limits

Secrets never appear in code, logs, snapshots or error messages. Webhook signing secrets are shown once, when the endpoint is created.

Personal data

  • Subscriber email addresses are visible only to admins, and only masked (a***@example.com).
  • The subscribe form keeps visitors' IP addresses only as keyed hashes. Better Auth records a member's IP address on each session, and keys its sign-in rate limits by address.
  • The status page and every published file contain public information only: nothing from a draft, dismissed or internal incident.

Infrastructure

  • No NAT gateway and no Lambda in a VPC; Aurora sits in isolated subnets and is reached only through the Data API with IAM.
  • Buckets are private, encrypted, and refuse plain HTTP; CloudFront reads them through origin access control.
  • Every stack passes cdk-nag's AWS Solutions rules, with each exception explained in code.
  • GitHub Actions deploys through OIDC: no AWS keys are stored in GitHub, and the deploy role trusts only main of one repository, by its immutable ids.
  • Third-party actions are pinned to commit SHAs, installs use the frozen lockfile, and only esbuild may run an install script.

Reporting a vulnerability

Report privately through the repository's Security tab, Report a vulnerability. See SECURITY.md in the repository for what's in scope.

On this page