Skip to main content

Authentication

All endpoints under /flare/v1/* require authentication via an API key. Canonical Flare health endpoints (/flare/health/live and /flare/health/ready) are public and do not require authentication.

Platform endpoints (/platform/v1/*) use JWT tokens obtained from the login endpoint. Email and password management endpoints are anonymous and IP-rate-limited.

API Key Format

API keys follow a structured prefix format that indicates the environment:

Prefix patternEnvironment
zf_live_sk_...Production
zf_test_sk_...Test

Each key has the shape zf_<env>_sk_<32-char-hex> — the zf_<env>_sk_ prefix is 11 characters and the random body is a 32-character lowercase hex string (16 bytes of cryptographically random data), for a total length of 43 characters.

The first 24 characters of the key serve as a prefix used for constant-time project lookup. The full key is verified against a stored SHA-256 hash to prevent timing attacks.

Using the API Key

Include your API key in the Authorization header as a Bearer token on every /flare/v1/* request:

Authorization: Bearer <your_api_key>

curl example

curl -X GET https://api.zeridion.com/flare/v1/jobs \
-H "Authorization: Bearer zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"

C# HttpClient example

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4");

var response = await client.GetAsync("https://api.zeridion.com/flare/v1/jobs");
tip

If you are using the Zeridion.Flare SDK, authentication is handled automatically. Set your API key in configuration:

builder.Services.AddZeridionFlare(options =>
{
options.ApiKey = "zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
});

Request ID

Every authenticated request is assigned a unique request ID (a ULID). This ID:

  • Is generated before any endpoint logic runs
  • Is included in all error responses as request_id
  • Can be used when contacting support to trace a specific request

Auth Flow

The Authorization header is verified per request and resolves to a project ID and plan.

Error Responses

If authentication fails, the API returns a 401 Unauthorized response. The error code differs by surface:

  • /flare/v1/* (API-key surface) — every authentication failure returns code unauthorized (ApiKeyAuthMiddleware).
  • /platform/v1/auth/login (JWT surface) — a wrong email, password, or TOTP code returns code invalid_credentials instead. See POST /platform/v1/auth/login below.
StatusCodeSurfaceWhen
401unauthorized/flare/v1/*Missing Authorization header, invalid Bearer format, unknown key, hash mismatch
401invalid_credentials/platform/v1/auth/loginWrong email, wrong password, or wrong TOTP code on login
{
"error": {
"code": "unauthorized",
"message": "Invalid API key.",
"request_id": "01JAXBKM3N4P5Q6R7S8T9UVWXY"
}
}

See the Errors page for the full error code reference and the Rate Limits page for request throttling details.

Idempotency

Idempotency is supported in two complementary ways:

  1. The body-level idempotency_key field on POST /flare/v1/jobs (legacy surface — see Jobs API). A duplicate request with the same value and the same body returns the original 201 Created response plus an Idempotent-Replay: true header; a duplicate value paired with a different body returns 409 idempotency_key_reuse.
  2. The RFC-style Idempotency-Key: <value> HTTP header on any state-changing endpoint. A duplicate request with the same body returns the original response status and body plus an Idempotent-Replay: true header. A duplicate key with a different body returns 409 idempotency_key_reuse. Keys expire 24 h after first use.

Dashboard auth (JWT)

The endpoints under /platform/v1/auth/* back the dashboard's signup, login, profile, password, and 2FA flows. The signature-verified billing webhook and a few admin endpoints aside, every other /platform/v1/* route consumes a JWT minted by POST /platform/v1/auth/login.

Anonymous endpoints (signup, verify, login, resend, forgot, reset) are IP-rate-limited to 20 requests per 15-minute window per IP. Exceeding the limit returns 429 Too Many Requests with code rate_limit_exceeded. Signed inbound webhook paths (Stripe, SendGrid) are exempt.

Authenticated endpoints (logout, change-password, me, profile, all 2FA endpoints) accept either an Authorization: Bearer <jwt> header or the zf_jwt httpOnly Secure SameSite=Strict cookie that login installs. Cross-origin browser callers must use credentials: 'include' for the cookie to round-trip.

POST /platform/v1/auth/signup

Create a new account. The user receives a verification email containing a single-use token (24 h TTL). Until they call POST /platform/v1/auth/verify with the token, login returns 403 email_not_verified.

Request body

FieldTypeRequiredDescription
emailstringYesEmail address. Must be a valid address; max 320 characters.
passwordstringYesAccount password. Min 8, max 128 characters.
accept_termsbooleanYesMust be true. The signup is recorded with a TermsAcceptedAt timestamp.
planstringNoPlan slug the user intends to start on (free, starter, pro, business). Used for the post-verify upsell flow. Defaults to free.
{
"email": "you@example.com",
"password": "correct-horse-battery-staple",
"accept_terms": true,
"plan": "free"
}

Response — 200 OK

{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"message": "Account created. Check your email to verify."
}

Error responses

StatusCodeWhen
400validation_errorEmail is invalid, password too short/long, or accept_terms is missing.
409email_in_useAn account with this email already exists.

POST /platform/v1/auth/verify

Exchange the single-use token from the verification email for a verified account. On first verify the server also auto-creates the user's first project and an owner membership row.

Request body

FieldTypeRequiredDescription
tokenstringYesToken from the verification email.

Response — 200 OK

{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"project_id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"project_name": "you's Project",
"api_key": "zf_live_sk_<plaintext_once>",
"message": "Email verified. Your first project and API key have been created."
}

The api_key is shown exactly once here. Store it before closing the response.

Error responses

StatusCodeWhen
400invalid_verification_tokenToken not found, already used, or expired (24-hour TTL).

POST /platform/v1/auth/login

Exchange email and password (and a TOTP code if 2FA is enrolled) for a JWT.

The JWT is never returned in the response body. It is set on a zf_jwt httpOnly Secure SameSite=Strict cookie that the JWT bearer handler reads back on every subsequent request.

Brute-force defense. Two independent counters guard this endpoint and both fire on every login attempt — they work together so a single attacker IP can't credential-stuff a single account AND a botnet can't credential-stuff one account from many IPs:

  • Per-IP20 POSTs per 15-minute sliding window per IP across all anonymous /platform/v1/auth/* endpoints (signup, verify, login, resend, forgot, reset). Hitting the cap returns 429 rate_limit_exceeded. The per-IP counter increments on every POST (successful or failed), not just failures — abusive clients can't simply rotate passwords for free.
  • Per-account10 failed attempts per 15-minute sliding window per user. Hitting the threshold returns 429 account_locked with a Retry-After header. The check runs before password verification so the lockout latency does not leak password-complexity timing. A successful login resets the counter to 0.

Request body

FieldTypeRequiredDescription
emailstringYesRegistered email address.
passwordstringYesAccount password.
totp_codestringConditional6-digit TOTP code. Required when 2FA is enabled on the account.
recovery_codestringConditionalSingle-use recovery code in the format xxxx-xxxx. Alternative to totp_code.
{
"email": "you@example.com",
"password": "correct-horse-battery-staple",
"totp_code": "123456"
}

Response — 200 OK

{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"email_verified": true,
"recovery_code_used": false,
"projects": [
{
"id": "prj_01...",
"name": "My Project",
"plan": "starter",
"api_key_prefix": "zf_live_sk_a1b2c3d4e5f6a" // gitleaks:allow
}
]
}

The Set-Cookie header on the response carries the JWT:

Set-Cookie: zf_jwt=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/

Error responses

StatusCodeWhen
401invalid_credentialsEmail or password is wrong, or TOTP code is wrong.
403email_not_verifiedEmail has not been verified yet.
403totp_required2FA is enabled but no totp_code (or recovery_code) was supplied.
429account_lockedPer-account failed-attempt threshold reached. Includes Retry-After.
429rate_limit_exceededPer-IP rate limit exceeded.

POST /platform/v1/auth/logout

Clear the zf_jwt cookie. Idempotent — returns 204 regardless of whether the cookie was set on the way in.

Request

POST /platform/v1/auth/logout

No request body.

Response — 204 No Content

The Set-Cookie header on the response invalidates the JWT cookie:

Set-Cookie: zf_jwt=; HttpOnly; Secure; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT

POST /platform/v1/auth/resend

Resend the email verification link for an unverified account. Sends a fresh 24-hour token.

To prevent email enumeration, the response is always 200 OK with the same message regardless of whether the email is registered or already verified.

Request body

FieldTypeRequiredDescription
emailstringYesEmail address of the unverified account. Max 320 characters.

Response — 200 OK

{
"message": "If your email is registered and unverified, a new verification link has been sent."
}

Rate limit: 20 POST requests per 15 minutes per IP (shared with all anonymous /platform/v1/auth/* endpoints).


POST /platform/v1/auth/forgot

Request a password reset link. A reset token valid for 1 hour is emailed to the address if it is registered.

To prevent timing-based email enumeration, the endpoint always waits at least 300 ms before responding and always returns the same message.

Request body

FieldTypeRequiredDescription
emailstringYesRegistered email address. Max 320 characters.

Response — 200 OK

{
"message": "If an account with that email exists, a password reset link has been sent."
}

Rate limit: 20 POST requests per 15 minutes per IP.


POST /platform/v1/auth/reset

Reset the account password using the token from the forgot-password email. The server bumps User.PasswordChangedAt, which invalidates every JWT whose iat (issued-at) precedes the change — so any existing session anywhere is logged out.

Request body

FieldTypeRequiredDescription
tokenstringYesReset token from the email link.
new_passwordstringYesNew password. Min 10, max 128 characters.

Response — 200 OK

{
"message": "Password has been reset. You can now log in with your new password."
}

Error responses

StatusCodeWhen
400invalid_reset_tokenToken not found, already used, or expired (1-hour TTL).

POST /platform/v1/auth/change-password

Change the password for the currently logged-in user. Like /auth/reset, this updates PasswordChangedAt and invalidates older JWTs.

Authentication: JWT required.

Request body

FieldTypeRequiredDescription
current_passwordstringYesThe user's existing password.
new_passwordstringYesNew password. Min 10, max 128 characters.

Response — 200 OK

{ "message": "Password updated." }

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT.
401invalid_credentialscurrent_password is wrong.

GET /platform/v1/auth/me

Return the full profile object the dashboard shell hydrates on every page load: the user, the account, all owned projects (with API key prefixes only — never plaintext), and any product entitlements.

Authentication: JWT required.

Response — 200 OK

{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"email_verified": true,
"created_at": "2025-01-01T00:00:00Z",
"is_admin": false,
"display_name": "Sam",
"timezone": "Europe/London",
"locale": "en-GB",
"totp_enabled": true,
"account": {
"id": "acct_01...",
"name": "Sam's Account"
},
"products": [
{ "product_slug": "flare", "enabled": true, "plan": "starter" }
],
"projects": [
{
"id": "prj_01...",
"name": "My Project",
"plan": "starter",
"api_key_prefix": "zf_live_sk_a1b2c3d4e5f6a" // gitleaks:allow
}
]
}
FieldTypeDescription
is_adminbooleantrue only for Zeridion staff accounts; surfaced so the dashboard can render admin-only UI.
totp_enabledbooleantrue once the user has completed TOTP enrollment via /2fa/verify.
accountobjectThe user's Account record. If no row exists yet a synthetic placeholder ({ "id": "", "name": "<email-local-part>'s Account" }) is returned.
productsarrayPer-product entitlement rows.
projectsarrayAll projects owned by the user, ordered by creation time. API keys are exposed only as their 24-character prefix.

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT.
404user_not_foundThe authenticated user no longer exists.

PATCH /platform/v1/auth/profile

Update mutable profile fields (display name, timezone, locale). Each field is optional; missing fields are left unchanged.

Authentication: JWT required.

Request body

FieldTypeRequiredDescription
display_namestring | nullNoTrimmed; max 100 characters. Pass an empty string to clear.
timezonestringNoAn IANA timezone identifier (e.g. America/New_York). Validated server-side via TimeZoneInfo.FindSystemTimeZoneById.
localestringNoA BCP-47 locale (e.g. en-GB, pt-BR). Validated against the runtime culture catalog — synthetic / unknown locales are rejected.

Response — 200 OK

{ "message": "Profile updated." }

Error responses

StatusCodeWhen
400display_name_too_longdisplay_name exceeds 100 characters.
400invalid_timezonetimezone is not a known IANA identifier.
400invalid_localelocale is not a real BCP-47 culture in the runtime catalog.
401unauthorizedMissing or invalid JWT.
404user_not_foundThe authenticated user no longer exists.

Two-factor authentication (TOTP)

Zeridion supports TOTP (RFC 6238) two-factor authentication via the standard otpauth:// provisioning URI, which any authenticator app (1Password, Authy, Google Authenticator, etc.) can scan. The TOTP secret is encrypted at rest with AES-256-GCM via EncryptedStringConverter, using the master key from Encryption:MasterKey — even a database dump cannot recover the secret without the key.

The verification window is ±1 step (30 s) to tolerate clock skew. The server persists LastTotpStep per user so that a code accepted at step N cannot be replayed at step N+1 to defeat one-time-use.

All four endpoints below require a valid JWT.

POST /platform/v1/auth/2fa/setup

Generate a fresh TOTP secret and provisioning URI for the current user. Refuses with 409 totp_already_enabled if 2FA is already active — call /2fa/disable first to start over.

Response — 200 OK

{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/Zeridion:you@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Zeridion&algorithm=SHA1&digits=6&period=30"
}
FieldTypeDescription
secretstringBase32-encoded shared secret. Display this so the user can enter it manually if they cannot scan the QR code.
otpauth_uristringStandard otpauth:// URI to render as a QR code.

2FA is not active after /setup — the user must enter a code from their authenticator and submit it via /2fa/verify to complete enrollment.

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT.
409totp_already_enabled2FA is already enabled.

POST /platform/v1/auth/2fa/verify

Confirm enrollment by submitting a code from the authenticator app. On success, 2FA is activated for the account and subsequent logins must include totp_code (or recovery_code).

Request body

FieldTypeRequiredDescription
codestringYes6-digit TOTP code.

Response — 200 OK

{
"enabled": true,
"enabled_at": "2026-05-15T12:00:00.000Z"
}

Error responses

StatusCodeWhen
400validation_errorcode is missing.
400invalid_totp_codeThe code does not match within the ±1-step window.
400totp_not_initialized/2fa/verify was called before /2fa/setup. Call setup first to generate the secret.
401unauthorizedMissing or invalid JWT.

POST /platform/v1/auth/2fa/disable

Disable 2FA for the current user. Requires both the current password and a current TOTP code — knowing only the session cookie (e.g. via session hijack) is not enough to disable MFA.

Request body

FieldTypeRequiredDescription
passwordstringYesCurrent account password.
codestringYesCurrent 6-digit TOTP code.

Response — 200 OK

{ "message": "Two-factor authentication disabled." }

The encrypted TOTP secret and any existing recovery codes are deleted.

Error responses

StatusCodeWhen
400validation_errorpassword or code is missing.
400invalid_totp_codeThe TOTP code is wrong.
401unauthorizedMissing or invalid JWT.
401invalid_credentialsThe password is wrong.
409totp_not_enabled2FA was not enabled to begin with.

POST /platform/v1/auth/2fa/recovery/regenerate

Issue a fresh batch of 10 single-use recovery codes in xxxx-xxxx format. The plaintext codes are returned exactly once in the response — only the SHA-256 hash of each code is persisted, so a database dump cannot recover them. Any previously-issued recovery codes are invalidated.

Response — 200 OK

{
"codes": [
"abcd-1234",
"efgh-5678",
"ijkl-9012",
"mnop-3456",
"qrst-7890",
"uvwx-1234",
"yzab-5678",
"cdef-9012",
"ghij-3456",
"klmn-7890"
],
"message": "10 new recovery codes generated. Store them securely — they will not be shown again."
}

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT.
409totp_not_enabled2FA must be enabled before recovery codes can be generated.

API key management

These endpoints require a valid JWT token (Authorization: Bearer <jwt_token>) and a project role of admin or higher (i.e. admin or owner). See Projects → Roles for the full role hierarchy.

POST /platform/v1/projects/{projectId}/keys/rotate

Generate a new API key for a project. The old key remains valid for a 24-hour grace window so deployed workers can be updated without a hard cutover.

:::warning Store the new key immediately The plaintext API key is returned exactly once in the response. It cannot be retrieved again. Store it securely (e.g. in Azure Key Vault or your CI/CD secrets store) before closing the response. :::

Request

POST /platform/v1/projects/{projectId}/keys/rotate
Authorization: Bearer <jwt_token>

No request body.

Response — 200 OK

{
"api_key": "zf_live_sk_<new_full_key>",
"rotated_at": "2026-05-11T12:00:00.000Z"
}

Grace window behaviour

After rotation:

  • The new key is active immediately.
  • The old key remains accepted for 24 hours (GraceExpiresAt). After that, requests using the old key are rejected with 401 Unauthorized.
  • Rapid back-to-back rotations refresh the grace window of the previous old key rather than creating duplicate rows.

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT token.
403forbiddenThe caller's role is below admin.
404project_not_foundProject ID does not exist.
409key_revokedThe project's key has been permanently revoked; rotation is not possible.

DELETE /platform/v1/projects/{projectId}/keys/revoke

Permanently revoke the project's API key. After revocation, no key (including any grace-window keys from a prior rotation) will be accepted. Cache eviction is immediate, so the rejection takes effect within milliseconds.

:::danger No recovery Revocation is permanent. There is no undo. You will need to create a new project to get a new API key, or contact support. :::

Request

DELETE /platform/v1/projects/{projectId}/keys/revoke
Authorization: Bearer <jwt_token>

No request body.

Response — 200 OK

{
"revoked_at": "2026-05-11T12:00:00.000Z"
}

Error responses

StatusCodeWhen
401unauthorizedMissing or invalid JWT token.
403forbiddenThe caller's role is below admin.
404project_not_foundProject ID does not exist.
409already_revokedThe key is already revoked.