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
Authorizationheader 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 layer — createAuth() 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 onceWeb 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:
- Extract the
Authorization: Bearer <token>header - Check if the token matches the
ADMIN_API_KEYenvironment variable (legacy key, full access) - If no match, SHA-256 hash the token and look it up in the
apiKeysdatabase table - Verify the key is not revoked and not expired
- Check that the key's scopes permit the requested operation. For admin routes this is hierarchical (GET requires
read, mutations requirejourney-adminorfull-admin); the data-plane routes require the orthogonalingestscope instead (see Key Scopes) - 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-charscurl -H "Authorization: Bearer hsk_your-secret-admin-key-at-least-32-chars" \
http://localhost:3002/v1/admin/contactsThe 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:
| Scope | Kind | What it allows |
|---|---|---|
read | hierarchical | All GET admin endpoints -- list contacts, view metrics, browse events, inspect journeys |
journey-admin | hierarchical | Everything in read, plus journey management -- enable/disable journeys, enroll users, cancel instances |
full-admin | hierarchical | Everything admin -- key management, bulk imports/exports, alerts, event replay, email resend. Also implies ingest |
ingest | orthogonal | The 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?
| Task | Required scope |
|---|---|
| View metrics dashboard | read |
| List contacts | read |
| Browse email history | read |
| View journey states | read |
| Enable/disable a journey | journey-admin |
| Enroll a user in a journey | journey-admin |
| Cancel a journey instance | journey-admin |
| Create or revoke API keys | full-admin |
| Import contacts | full-admin |
| Export contacts | full-admin |
| Create alert rules | full-admin |
| Replay events | full-admin |
| Resend failed emails | full-admin |
| Retry DLQ entries | full-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/contactsThe 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-keysRevoked 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:
- Create a new key with the same name and scopes
- Update your systems to use the new key
- 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:
| Setting | Value |
|---|---|
| Window | 1 minute |
| Max requests | 100 per API key |
| Backend | Redis (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:
| Header | Description |
|---|---|
X-RateLimit-Remaining | Requests remaining in the current window |
Retry-After | Seconds 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
Authorizationheader 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_KEYis 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-adminorfull-adminscope - Key management and bulk operations require
full-adminscope
429 Too Many Requests
You have exceeded the rate limit (100 requests per minute per key).
- Read the
Retry-Afterheader 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_KEYenvironment 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:
- Set a strong
ADMIN_API_KEY— at least 32 random characters - Set a strong
BETTER_AUTH_SECRET— used for session signing and reset-token derivation - Provision Redis and set
REDIS_URL— required for sessions, reset tokens, and cross-replica auth rate limiting - Mint the first Studio admin from your server —
hogsend studio admin createorSTUDIO_ADMIN_EMAIL; never expose a create path over HTTP - Use HTTPS — all API keys and session cookies are transmitted in headers; never use HTTP in production
- Create scoped keys — avoid sharing the legacy key; create read-only keys for monitoring
- Set key expiry dates — especially for CI/CD and one-time migration keys
- Monitor audit logs — periodically review for unexpected mutations
- Configure email webhook verification — set your provider's webhook secret (
RESEND_WEBHOOK_SECRETfor the default Resend provider; Postmark uses HTTP Basic creds viaPOSTMARK_WEBHOOK_USER/POSTMARK_WEBHOOK_PASS) to validate inbound delivery events - Review rate limit settings — 100 req/min per API key is suitable for most teams but can be adjusted via environment configuration
Upgrading & Customizing
Upgrade the engine with pnpm up, run both migration tracks, and reach for the Extend → Patch → Eject ladder when you need to change engine behaviour.
Studio
The Hogsend Studio admin UI — observe sends, templates, journeys, contacts, and suppressions. Open it at /studio or run it locally with the CLI.