Hogsend
Operating

Authentication

Two auth surfaces — the human Studio login (CLI/env first admin, sign-up disabled) and machine API keys (legacy or scoped database-backed).

Hogsend has two authentication surfaces:

  • Studio login — the human path. A logged-in operator using the Studio admin UI, authed by a Better Auth session cookie. Public sign-up is disabled; the first admin is minted from your server (see Studio admin login below).
  • API keys — the machine path. Bearer tokens in the Authorization header for the CLI, CI, agents, and your own app code. This is the bulk of the page below.

API keys guard two planes: the admin/ops plane (/v1/admin/*) and the public data plane (/v1/contacts, /v1/events, /v1/emails, /v1/lists — see the Data API). Hogsend supports two key modes — a legacy environment-variable key for quick setup, and database-backed keys with scoped permissions for production use.

Both planes are bearer-authed, but the data plane is not under /v1/admin. A key reaches the data plane only via the ingest scope (or full-admin), and an ingest-only key reaches nothing on /v1/admin/*. See Key Scopes below for why ingest is orthogonal, not hierarchical.

Studio admin login

The Studio is authed by a Better Auth session, not an API key. Public web sign-up is disabled at the auth layercreateAuth() sets emailAndPassword: { enabled: true, disableSignUp: true, minPasswordLength: 8, maxPasswordLength: 128 }. There is no unauthenticated network path that creates any user or admin: POST /api/auth/sign-up/email returns 400 EMAIL_PASSWORD_SIGN_UP_DISABLED for everyone, and so does the in-process auth.api.signUpEmail (the disableSignUp check lives inside the sign-up handler in better-auth 1.6.11). There is no setup token and no web create-admin form — both are gone.

Public sign-up is disabled, so the first Studio admin is created from your server — run hogsend studio admin create (it needs DATABASE_URL + BETTER_AUTH_SECRET + shell access), or set STUDIO_ADMIN_EMAIL (+ optional STUDIO_ADMIN_PASSWORD) and the API mints it on boot into an empty user table:

# CLI — DB-direct, no HTTP, no running API; env must be loaded
dotenvx run -- hogsend studio admin create --email admin@example.com
railway run hogsend studio admin create --email admin@example.com
pnpm studio:admin                                # scaffold wrapper, loads .env

# Env bootstrap — set on the deploy; mints on a zero-user DB at boot
STUDIO_ADMIN_EMAIL=admin@example.com
STUDIO_ADMIN_PASSWORD=...                         # optional; omit and one is auto-generated + printed once

Web Studio is login + self-service forgot/reset only. A zero-users instance renders a read-only info screen (no inputs, no network create path) pointing at hogsend studio admin create / STUDIO_ADMIN_*. Self-service reset uses Better Auth's /request-password-reset + /reset-password (single-use token, 15-minute TTL, sessions revoked on reset); it is live only when an email provider is configured. Locked out with no email provider? Recover with hogsend studio admin reset. The full first-admin and recovery story lives in the Studio and hogsend studio admin pages.

Redis is required for auth in production. createHogsendClient wires Better Auth's secondaryStorage to the shared engine Redis — it holds sessions, reset tokens, and rate-limit counters — gated on a raw REDIS_URL being set. With it, the sign-in (10/60s) and password-reset (5/60s) rate limiters are global across replicas and survive restarts; without it they are per-instance and lost on restart. Set REDIS_URL in production.

How Auth Works

When a request hits any /v1/admin/* endpoint, the auth middleware runs this sequence:

  1. Extract the Authorization: Bearer <token> header
  2. Check if the token matches the ADMIN_API_KEY environment variable (legacy key, full access)
  3. If no match, SHA-256 hash the token and look it up in the apiKeys database table
  4. Verify the key is not revoked and not expired
  5. Check that the key's scopes permit the requested operation. For admin routes this is hierarchical (GET requires read, mutations require journey-admin or full-admin); the data-plane routes require the orthogonal ingest scope instead (see Key Scopes)
  6. If neither mode matches, return 401 Unauthorized

If no admin key is configured at all (no ADMIN_API_KEY env var, no database keys), admin endpoints return 503 Service Unavailable.

Active database-backed keys are cached in memory for 60 seconds. A newly created key works immediately (cache miss triggers a DB lookup). A revoked key may still work for up to 60 seconds until its cache entry expires.

Legacy Key (Quick Start)

Set the ADMIN_API_KEY environment variable and you are ready to go:

# .env
ADMIN_API_KEY=hsk_your-secret-admin-key-at-least-32-chars
curl -H "Authorization: Bearer hsk_your-secret-admin-key-at-least-32-chars" \
  http://localhost:3002/v1/admin/contacts

The legacy key always has full admin access -- it can perform any operation. This is fine for local development and single-operator setups, but for production you should create scoped keys.

Creating Your First Database-Backed Key

Use the legacy key (or any existing full-admin key) to create a new scoped key:

curl -X POST http://localhost:3002/v1/admin/api-keys \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ops Dashboard",
    "scopes": ["read"],
    "expiresAt": "2026-12-31T00:00:00.000Z"
  }'
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "Ops Dashboard",
  "key": "hsk_abc123def456ghi789jkl012mno345pqr678",
  "keyPrefix": "hsk_abc1",
  "scopes": ["read"],
  "expiresAt": "2026-12-31T00:00:00.000Z",
  "createdAt": "2026-01-15T10:30:00.000Z"
}

Save the key value immediately. It is SHA-256 hashed before storage and will never be shown again. If you lose it, revoke the key and create a new one.

The keyPrefix field (first 8 characters) is stored in plaintext so you can identify keys in list views without exposing the full token.

Key Scopes

Each database-backed key has one or more scopes that control what it can do. There are two kinds of scope:

ScopeKindWhat it allows
readhierarchicalAll GET admin endpoints -- list contacts, view metrics, browse events, inspect journeys
journey-adminhierarchicalEverything in read, plus journey management -- enable/disable journeys, enroll users, cancel instances
full-adminhierarchicalEverything admin -- key management, bulk imports/exports, alerts, event replay, email resend. Also implies ingest
ingestorthogonalThe public data plane -- write /v1/contacts, /v1/events, /v1/emails, /v1/lists. Grants nothing on /v1/admin/*

The three admin scopes are hierarchical and cumulative -- a key with ["journey-admin"] implicitly has read; a key with ["full-admin"] can do everything on the admin plane.

ingest is different: it is orthogonal, not hierarchical. It is the data-plane scope, and it sits entirely outside the read < journey-admin < full-admin ladder.

A read or journey-admin key does NOT get ingest. Holding an admin tier grants nothing on the data plane. A key reaches the data plane only with an explicit ingest grant or full-admin (which implies it). Conversely, an ingest-only key reaches nothing on /v1/admin/*. Combine them (e.g. ["read", "ingest"]) when a single key needs both planes.

This is enforced by hasScope() in the engine's middleware/api-key.ts: a required orthogonal scope (ingest) is satisfied only by keyScopes.includes("ingest") || keyScopes.includes("full-admin") — never by a hierarchical rank. The full data-plane key story lives in the Data API authentication page.

Which scope do I need?

TaskRequired scope
View metrics dashboardread
List contactsread
Browse email historyread
View journey statesread
Enable/disable a journeyjourney-admin
Enroll a user in a journeyjourney-admin
Cancel a journey instancejourney-admin
Create or revoke API keysfull-admin
Import contactsfull-admin
Export contactsfull-admin
Create alert rulesfull-admin
Replay eventsfull-admin
Resend failed emailsfull-admin
Retry DLQ entriesfull-admin
Write contacts/events/emails/lists (data plane)ingest

A request with insufficient scope returns 403 Forbidden:

{ "error": "Insufficient scope" }

Key Lifecycle

Create

curl -X POST http://localhost:3002/v1/admin/api-keys \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI Pipeline",
    "scopes": ["read"],
    "expiresAt": "2026-06-01T00:00:00.000Z"
  }'

The name field is for your reference -- use it to identify what the key is for when reviewing the key list or audit logs. The expiresAt field is optional; keys without an expiry date are valid until revoked.

Use

Include the key as a bearer token in the Authorization header:

curl -H "Authorization: Bearer hsk_abc123def456ghi789..." \
  http://localhost:3002/v1/admin/contacts

The lastUsedAt timestamp on the key record updates when the key is used, so you can identify stale keys that should be cleaned up.

List

curl -H "Authorization: Bearer your-api-key" \
  http://localhost:3002/v1/admin/api-keys

Revoked keys are hidden by default. To see them:

curl -H "Authorization: Bearer your-api-key" \
  "http://localhost:3002/v1/admin/api-keys?includeRevoked=true"

Rotate

There is no in-place rotation endpoint. To rotate a key:

  1. Create a new key with the same name and scopes
  2. Update your systems to use the new key
  3. Revoke the old key
# Step 1: Create replacement
curl -X POST http://localhost:3002/v1/admin/api-keys \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "CI Pipeline (rotated)", "scopes": ["read"] }'

# Step 2: Update your CI config with the new key...

# Step 3: Revoke the old key
curl -X DELETE http://localhost:3002/v1/admin/api-keys/old-key-uuid \
  -H "Authorization: Bearer your-api-key"

Revoke

curl -X DELETE http://localhost:3002/v1/admin/api-keys/key-uuid \
  -H "Authorization: Bearer your-api-key"

Revocation sets revokedAt on the key record and immediately invalidates the in-memory cache entry. Due to the 60-second cache TTL, the revoked key may still be accepted by other API instances for a brief window in multi-instance deployments.

Best Practices

Use read-only keys for dashboards. Any monitoring tool, dashboard, or reporting pipeline that only reads data should use a read-scoped key. This limits the blast radius if the key is leaked.

Use journey-admin keys for automation tools. If you have a tool that enables/disables journeys or enrolls users, give it journey-admin scope. It does not need to manage API keys or run imports.

Reserve full-admin for operators. Only human operators and trusted automation (like your deploy pipeline) should have full-admin keys.

Set expiry dates on CI/CD keys. Pipeline keys should expire and be rotated regularly. Set a 90-day expiry and automate key rotation in your CI config.

Use short-lived keys for one-off tasks. Running a data migration? Create a full-admin key with a 24-hour expiry. It auto-expires when you are done.

Name keys descriptively. The name appears in audit logs. "CI Pipeline" or "Grafana Dashboard" is much more useful than "key1" when you are investigating an incident.

Audit key usage periodically. List your keys and check lastUsedAt -- revoke any key that has not been used in months.

Rate Limiting

All API endpoints (admin and non-admin) are protected by a sliding-window rate limiter:

SettingValue
Window1 minute
Max requests100 per API key
BackendRedis (primary), in-memory fallback

The Studio auth endpoints have their own Better Auth limiters on top of this — /sign-in/email at 10/60s and /request-password-reset at 5/60s. Those counters live in the shared Redis secondaryStorage, so they are global across replicas (set REDIS_URL in production — see Studio admin login).

Rate limit status is communicated via response headers:

HeaderDescription
X-RateLimit-RemainingRequests remaining in the current window
Retry-AfterSeconds until the next request is accepted (only on 429)

When the limit is exceeded:

HTTP/1.1 429 Too Many Requests
Retry-After: 32
{ "error": "Rate limit exceeded" }

To stay under the limit: use bulk endpoints (/import, /enroll/batch, /replay) instead of looping individual calls, and cache read responses client-side when possible.

Troubleshooting

401 Unauthorized

The token is missing, malformed, or does not match any known key.

  • Verify the Authorization header format: Bearer <token> (note the space)
  • Check that the key has not been revoked (GET /v1/admin/api-keys?includeRevoked=true)
  • Check that the key has not expired
  • If using the legacy key, verify ADMIN_API_KEY is set in your environment

403 Forbidden

The key is valid but does not have the required scope for this operation.

  • Check the key's scopes (GET /v1/admin/api-keys)
  • POST/PATCH/DELETE operations require journey-admin or full-admin scope
  • Key management and bulk operations require full-admin scope

429 Too Many Requests

You have exceeded the rate limit (100 requests per minute per key).

  • Read the Retry-After header to know when to retry
  • Use bulk endpoints instead of individual calls
  • If you need higher limits, adjust the rate limit configuration

503 Service Unavailable

No authentication method is configured. The API cannot accept admin requests.

  • Set the ADMIN_API_KEY environment variable, or
  • Create at least one database-backed key (requires the legacy key to bootstrap)

For the full endpoint specification of key management endpoints, see the API Reference.

Production Security Checklist

When deploying Hogsend to production:

  1. Set a strong ADMIN_API_KEY — at least 32 random characters
  2. Set a strong BETTER_AUTH_SECRET — used for session signing and reset-token derivation
  3. Provision Redis and set REDIS_URL — required for sessions, reset tokens, and cross-replica auth rate limiting
  4. Mint the first Studio admin from your serverhogsend studio admin create or STUDIO_ADMIN_EMAIL; never expose a create path over HTTP
  5. Use HTTPS — all API keys and session cookies are transmitted in headers; never use HTTP in production
  6. Create scoped keys — avoid sharing the legacy key; create read-only keys for monitoring
  7. Set key expiry dates — especially for CI/CD and one-time migration keys
  8. Monitor audit logs — periodically review for unexpected mutations
  9. Configure email webhook verification — set your provider's webhook secret (RESEND_WEBHOOK_SECRET for the default Resend provider; Postmark uses HTTP Basic creds via POSTMARK_WEBHOOK_USER/POSTMARK_WEBHOOK_PASS) to validate inbound delivery events
  10. Review rate limit settings — 100 req/min per API key is suitable for most teams but can be adjusted via environment configuration

On this page