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
| Code | HTTP | Type | When |
|---|---|---|---|
authentication_required | 401 | authentication_error | Caller 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). |
forbidden | 403 | authorization_error | Caller is authenticated but lacks the permission required for this operation, or attempted to access a resource outside their organization. |
not_found | 404 | not_found | The 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). |
conflict | 409 | conflict | A precondition failed: email already registered, organization slug taken, MFA already enabled, last-owner demotion attempted, invitation already accepted/revoked. |
invalid_input | 400 | validation_error | Body failed validation. The message field carries the field-level diagnostic from the validator crate. |
rate_limited | 429 | rate_limited | Too many attempts. Currently returned by POST /v1/auth/mfa/challenge after 5 failed codes against the same mfa_token. |
mfa_required | 401 | mfa_required | Step-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_required | 403 | license_required | The 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_error | 500 | internal_error | Database, 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:
POST /v1/auth/refresh.- If the refresh succeeds → retry the original request.
- 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."