Skip to main content

Errors

Every error response follows the same envelope:

{
"error": {
"code": "invalid_input",
"message": "email must be a valid email address",
"type": "validation_error"
}
}

The code field is the canonical machine-readable identifier — switch on it in code, never on the message.

Error catalog

CodeHTTPTypeWhen
authentication_required401authentication_errorCaller has no valid session, or the session is revoked, expired, or rejected by the middleware. Also returned by POST /v1/auth/login on bad credentials (intentionally constant-message).
forbidden403authorization_errorCaller is authenticated but lacks the permission required for this operation, or attempted to access a resource outside their organization.
not_found404not_foundThe requested resource doesn't exist OR exists but the caller doesn't own it (we don't distinguish — returning 403 in the second case would leak existence).
conflict409conflictA precondition failed: email already registered, organization slug taken, MFA already enabled, last-owner demotion attempted, invitation already accepted/revoked.
invalid_input400validation_errorBody failed validation. The message field carries the field-level diagnostic from the validator crate.
rate_limited429rate_limitedToo many attempts. Currently returned by POST /v1/auth/mfa/challenge after 5 failed codes against the same mfa_token.
mfa_required401mfa_requiredStep-up required: the route demands a recent MFA assertion and the caller doesn't have one. Distinct from the mfa_required LOGIN response (HTTP 200 with status: "mfa_required" in the body).
license_required403license_requiredThe caller tried to use a paid feature without a valid license. Returned only by premium modules (SCIM, SAML, audit-export); v0.1 OSS doesn't trigger this.
internal_error500internal_errorDatabase, JWT signing, password hashing, IO, or other server-side failure. The message field is generic; check server logs for the underlying cause.

Handling common cases

::: tip Defensive coding Always switch on error.code, not on HTTP status. A 400 can be either a malformed request OR a validation failure — they carry different meanings to the user. :::

401 on a "logged in" request

The most common reason is an expired access token. The standard response is:

  1. POST /v1/auth/refresh.
  2. If the refresh succeeds → retry the original request.
  3. If the refresh also returns 401 → the user is signed out. Redirect to login.

@identsphere/react does this automatically.

409 on registration

{
"error": {
"code": "conflict",
"message": "email already registered",
"type": "conflict"
}
}

The message distinguishes between:

  • email already registered — show "this email is in use; sign in instead?"
  • organization slug already taken — show "pick a different slug"

400 with validation context

Validation errors carry the underlying validator crate diagnostic in the message:

{
"error": {
"code": "invalid_input",
"message": "password: Validation error: length [{\"min\": Number(12), \"max\": Number(256)}]",
"type": "validation_error"
}
}

For end-user messaging, prefer to validate client-side first and use the server response only as a backstop.

429 on MFA challenge

{
"error": {
"code": "rate_limited",
"message": "rate limited",
"type": "rate_limited"
}
}

The mfa_token is locked for the rest of its 5-minute lifetime. The user must restart the login flow to get a fresh token.

Error responses to login

POST /v1/auth/login deliberately returns authentication_required for both:

  • The email doesn't exist.
  • The password is wrong.

You cannot distinguish these client-side — this defeats account enumeration. Treat both as "wrong credentials, try again."