Skip to content

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.

Terminal window
curl -H "X-Api-Key: $AURA_API_KEY" \
https://api.auradonors.com/api/tenants/$TENANT_ID/contacts

The 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.

A key belongs to exactly one tenant. The auth handler:

  1. SHA-256 hashes the supplied header value.
  2. Looks the hash up in the host-DB tenant_api_keys table.
  3. Verifies the row is IsActive = true, not soft-deleted, and not past ExpiresAt.
  4. Verifies the owning tenant is IsActive = true.
  5. Verifies the tenant has api-access:enabled = true in tenant_features.
  6. Resolves the tenant’s connection string via ITenantResolver.
  7. Builds a ClaimsPrincipal carrying the tenant id, slug, and one permission claim 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 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:

ScopeWhat it unlocks
contacts:viewList + read individual contacts and their dashboards.
contacts:createCreate new contacts.
donations:viewRead donation history.
orders:createCreate orders (which is how donations and item sales record).
recurring-donations:viewRead recurring schedules.
reports:view + reports:exportRun and export saved reports.
webhooks:manageRegister 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.

There is no rotate-in-place operation. Rotation is:

  1. Create a new key with the same scopes (or a tightened set).
  2. Deploy the new key to your integration’s secret store.
  3. Cut traffic over.
  4. 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.

Terminal window
# 1. Create the replacement
curl -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 traffic
curl -sS \
-H "X-Api-Key: $NEW_KEY" \
"https://api.auradonors.com/api/tenants/$TENANT_ID/api-keys" | jq '.[] | {name,lastUsedAt}'
# 4. Revoke the old key
curl -sS -X DELETE \
-H "X-Api-Key: $NEW_KEY" \
"https://api.auradonors.com/api/tenants/$TENANT_ID/api-keys/$OLD_KEY_ID"

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.

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.

A request gets 401 Unauthorized for any of:

  • The X-Api-Key header is missing.
  • The hashed key value does not match any active row in tenant_api_keys.
  • The key has expired (expiresAt is in the past).
  • The owning tenant is deactivated (tenants.is_active = false).
  • The tenant does not have api-access:enabled = true in tenant_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.

  • 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 against https://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 LastUsedAt and your monitoring; build the rotation discipline into your secret store.