Authentication
Every request to the Auradonors API authenticates via the X-Api-Key
header. There is no OAuth flow, no bearer-token model, no client-credentials
exchange, and no per-environment key promotion. A key is bound to one
tenant, carries an explicit set of permission scopes, and is rejected at
the auth handler whenever any of those preconditions fail.
Header model
Section titled “Header model”curl -H "X-Api-Key: $AURA_API_KEY" \ https://api.auradonors.com/api/tenants/$TENANT_ID/contactsThe header is mandatory on every endpoint under /api/tenants/... (except
for a small set of unauthenticated public endpoints like the marketing
contact form). Calls without it receive 401 Unauthorized with no body
content distinguishing “missing header” from “wrong header” — the API does
not leak whether a particular key existed.
Per-tenant scoping
Section titled “Per-tenant scoping”A key belongs to exactly one tenant. The auth handler:
- SHA-256 hashes the supplied header value.
- Looks the hash up in the host-DB
tenant_api_keystable. - Verifies the row is
IsActive = true, not soft-deleted, and not pastExpiresAt. - Verifies the owning tenant is
IsActive = true. - Verifies the tenant has
api-access:enabled = trueintenant_features. - Resolves the tenant’s connection string via
ITenantResolver. - Builds a
ClaimsPrincipalcarrying the tenant id, slug, and onepermissionclaim per scope on the key.
Any failure in 1–6 returns the same generic 401. There is no separate
“key disabled” code, no “feature off” code, no “tenant frozen” code —
distinguishing them publicly would be a directory-enumeration vector.
The path parameter {tenantId} in the URL must match the key’s owning
tenant. Mismatched ids return 404 Not Found from the tenant-resolution
step before the handler runs.
Scopes
Section titled “Scopes”Scopes are strings that mirror the in-app permission keys. The full list
lives in the permissions catalog — that page
is generated from Aura.Domain.Constants.Permissions so it never drifts
from the code.
A scope is granted on a key by including it in the scopes array at
creation time. The auth handler stamps each scope as a permission claim
on the principal; the Mediator pipeline’s PermissionGuardBehavior then
gates each command/query against its [RequirePermission] attribute.
Common scopes for an integration:
| Scope | What it unlocks |
|---|---|
contacts:view | List + read individual contacts and their dashboards. |
contacts:create | Create new contacts. |
donations:view | Read donation history. |
orders:create | Create orders (which is how donations and item sales record). |
recurring-donations:view | Read recurring schedules. |
reports:view + reports:export | Run and export saved reports. |
webhooks:manage | Register and update outbound webhook endpoints. |
The 403 you’ll see when a scope is missing carries a tenant-safe message
(Missing required permission: donations:view) — that’s authored by the
guard, not echoed from an exception. See Conventions → Errors
for the shape.
Key rotation
Section titled “Key rotation”There is no rotate-in-place operation. Rotation is:
- Create a new key with the same scopes (or a tightened set).
- Deploy the new key to your integration’s secret store.
- Cut traffic over.
- Revoke the old key once you’ve confirmed the new one is in use.
Watch the LastUsedAt timestamp on each key (returned by
GET /api/tenants/{tenantId}/api-keys) to verify the cutover landed
before you revoke.
# 1. Create the replacementcurl -sS -X POST \ -H "X-Api-Key: $OLD_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"nightly-export-job-2026-Q2","scopes":["contacts:view","donations:view"]}' \ "https://api.auradonors.com/api/tenants/$TENANT_ID/api-keys"
# 2. (Deploy the new plainTextKey to your integration.)
# 3. Confirm the new key is taking trafficcurl -sS \ -H "X-Api-Key: $NEW_KEY" \ "https://api.auradonors.com/api/tenants/$TENANT_ID/api-keys" | jq '.[] | {name,lastUsedAt}'
# 4. Revoke the old keycurl -sS -X DELETE \ -H "X-Api-Key: $NEW_KEY" \ "https://api.auradonors.com/api/tenants/$TENANT_ID/api-keys/$OLD_KEY_ID"Revocation
Section titled “Revocation”DELETE /api/tenants/{tenantId}/api-keys/{apiKeyId} returns 204 No Content
and immediately invalidates the key. Subsequent requests with the revoked
key get 401. Revocation is non-recoverable — there is no undelete. If
you revoke a key in error, mint a fresh one.
The same flow lives in the app at Administration → Integrations → API
Keys → Revoke. Both paths set IsActive = false and stamp the soft-delete
columns; the auth handler filters on both.
Expiration
Section titled “Expiration”A key may be created with an optional expiresAt timestamp. After that
moment passes the auth handler rejects every request with 401 until the
key is replaced. Expiration is hard — there’s no grace period and no
automatic rotation.
For long-lived service integrations, omit expiresAt. For temporary
contractor access, set it to the engagement’s end date plus a buffer.
What gets rejected
Section titled “What gets rejected”A request gets 401 Unauthorized for any of:
- The
X-Api-Keyheader is missing. - The hashed key value does not match any active row in
tenant_api_keys. - The key has expired (
expiresAtis in the past). - The owning tenant is deactivated (
tenants.is_active = false). - The tenant does not have
api-access:enabled = trueintenant_features.
A request gets 403 Forbidden when authentication succeeded but the key
lacks the scope a guard needs. The message names the missing scope so
you can reconcile it against the permissions catalog.
A request gets 404 Not Found when the URL’s {tenantId} doesn’t match
the key’s tenant.
Deliberate non-goals
Section titled “Deliberate non-goals”- No OAuth, no bearer-token, no JWT. The header model is the only authentication path for programmatic access.
- No per-environment keys. A production key works only against
https://api.auradonors.com; a sandbox key works only againsthttps://api-dev.auradonors.com. The two environments are separate tenant universes. - No automatic rotation reminders. If you set
expiresAt, set yourself a calendar reminder to rotate before it lands. - No key-derived JWTs or short-lived session tokens. Each request re-presents the long-lived key.
- No webhook of “key is about to expire.” Watch
LastUsedAtand your monitoring; build the rotation discipline into your secret store.