Login flow in depth
Every branch of POST /login — happy path, password policy, brute-force protection, and the 401 vs 404 distinction.
This guide walks through POST /api/v1/login end-to-end. If you're after the
five-minute introduction, see
Getting started; this page is for the
day you need to debug the unhappy path.
Request
POST /api/v1/login
Content-Type: application/jsonProp
Type
Happy path response
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "8c9a3a48-7b9d-...",
"expiresIn": 900,
"user": {
"id": "01HXY...",
"email": "you@febasi.com.br",
"username": null,
"tenantId": "01HX0...",
"tenantCode": "febasi"
}
}
}expiresIn is the access-token lifetime in seconds; defaults to 900 (15 minutes)
unless the tenant overrides it via auth_config.
Branches of failure
The Auth distinguishes carefully between what failed — clients can react differently to each.
| Code | HTTP | When |
|---|---|---|
TENANT_NOT_FOUND | 404 | The tenant code does not exist or is INACTIVE. |
INVALID_CREDENTIALS | 401 | Identifier not found or password mismatch. |
VALIDATION_ERROR | 400 | Body shape is wrong (Zod validation). |
PASSWORD_POLICY_VIOLATION | 400 | Password fails the tenant's policy on register (login itself only verifies the hash). |
RATE_LIMIT_EXCEEDED | 429 | More than 5 logins/min from the same IP. |
Why one error for two failures?
INVALID_CREDENTIALS is returned for both "user does not exist" and "wrong
password". This is intentional: it prevents user enumeration. Combined with
the dummy bcrypt comparison run when the user is missing, neither the
response body nor the response time leaks whether an identifier exists in
the tenant.
Brute-force protection
POST /login is rate-limited to 5 requests per minute per IP. The limit
is enforced on the Fastify side (in-memory by default, Redis-backed when
configured). Hitting the limit returns:
{ "success": false, "error": "Rate limit exceeded", "code": "RATE_LIMIT_EXCEEDED" }Beyond rate limits, the security metrics endpoint
(GET /api/v1/tenants/me/metrics/security) surfaces brute-force indicators:
- Failed attempts in the last 5 minutes.
- Unique IPs hitting failed logins.
- IPs with ≥ 5 failures in 24 hours (flagged as suspicious).
You can wire alerts on top of this — the threshold for "possible brute force" is >10 failures in a 5-minute window.
Edge cases worth knowing
What you receive in the JWT
The access token already carries:
userId,tenantId,tenantCode- The user's
emailandusername(whichever they have) - A flat array of
roles(role names) - A flat array of
permissions(effective permissions, deduped)
So most consumers do not call /me after login — they decode the JWT for
display and route protection. /me exists for clients that need to refresh
the user state without rotating tokens (e.g., to pick up a new role).