Local Authentication ~8 min
Overview
In addition to Keycloak OAuth, Shell supports local username/password authentication. Credentials are stored in user_credentials (bcrypt via Bun.password, cost=10).
Register
POST /api/auth/register
Content-Type: application/json
{
"email": "jane@acme.com",
"username": "jane",
"password": "SecurePass123",
"firstName": "Jane",
"lastName": "Doe",
"plan": "community"
}What happens:
- Validates password strength (8–128 chars, uppercase, lowercase, digit)
- Provisions organization (based on email domain — see Organizations)
- Creates user + hashed credentials in a transaction
- Assigns default roles
- Returns Shell JWT
{
"data": {
"accessToken": "<shell-jwt>",
"refreshToken": "<refresh-token>",
"expiresIn": 86400,
"tokenType": "Bearer",
"user": {
"id": "uuid",
"email": "jane@acme.com",
"username": "jane",
"organizationId": "org-uuid"
}
}
}Token validity
- Access token: 24 hours (
expiresIn: 86400) - Refresh token: 7 days
Login
POST /api/auth/local-login
Content-Type: application/json
{ "username": "jane", "password": "SecurePass123" }Accepts either username or email in the username field.
Same response shape as register.
Account locking
After 5 consecutive failures, the account is locked for 30 minutes. The lock lifts automatically — no manual unlock needed.
Refresh token
POST /api/auth/refresh
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{ "refreshToken": "<refresh-token>" }{
"data": {
"accessToken": "<new-shell-jwt>",
"expiresIn": 3600
}
}The existing refresh token remains valid until its 7-day expiry.
Logout
GET /api/auth/logout
Authorization: Bearer <shell-jwt>Adds the current JWT to the token blacklist in Redis (TTL = token expiry + 60s buffer). All subsequent requests with this token return 401.
Token blacklist key format
blacklist:token:{first-32-chars-of-sha256(token)}Change password
POST /api/auth/change-password
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{
"currentPassword": "OldPass123",
"newPassword": "NewPass456!"
}Rules enforced:
- New password must meet strength requirements
- Cannot reuse the last 10 passwords (history checked against
user_credentials)
Password reset flow
1. Request reset link
POST /api/auth/request-password-reset
Content-Type: application/json
{ "email": "jane@acme.com" }Generates a reset token (expires in 1 hour). In production, this sends an email — configure your email transport in the application.
2. Confirm reset
POST /api/auth/confirm-password-reset
Content-Type: application/json
{
"token": "<reset-token>",
"newPassword": "BrandNewPass1"
}On success: old token is invalidated, new password is stored.
Token exchange (for services)
After login, a service can exchange the Shell JWT for a service-scoped JWT (1h expiry, includes service audience):
POST /api/auth/exchange
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{ "service": "hrm" }{
"data": {
"accessToken": "<service-jwt>",
"expiresIn": 3600,
"service": "hrm"
}
}Or exchange a Keycloak token directly:
POST /api/auth/exchange-keycloak
Content-Type: application/json
{ "token": "<keycloak-access-token>" }Password strength rules
| Rule | Requirement |
|---|---|
| Length | 8–128 characters |
| Uppercase | At least 1 (A-Z) |
| Lowercase | At least 1 (a-z) |
| Digit | At least 1 (0-9) |
| Special char | Optional (not required) |
| History | Cannot match last 10 passwords |