Description
Overview
The Users API provides functionality for managing users and authentication:
- User management (CRUD operations)
- Multi-factor authentication (MFA)
- Credential management
- Password management
- User registration
- User roles and permissions
- User context management
- User avatar management
Core Concepts
User Credentials
email: Email addressphone: Phone numbertelegram: Telegram IDtotp: Time-based One-Time Passwordwhatsapp: WhatsApp number
MFA Modes
off: MFA disabledemail: Email-based MFAphone: SMS-based MFAtotp: TOTP-based MFA
MFA Session Binding
Security Feature: The system implements cryptographic session binding for 2FA verification to prevent authentication bypass attacks.
Configuration: auth.2fa_challenge only controls whether /v1/verify-2FA requires the explicit X-MFA-Challenge header. It does not control whether challenge tokens can be used as authenticated bearer tokens — that separation is always enforced.
Backward Compatibility Mode (2fa_challenge: false)
/v1/verify-2FAcan operate without the explicitX-MFA-Challengeheader- Challenge tokens are still never accepted on protected routes or refresh endpoints
- MFA-enabled login responses remain pre-auth only (no authenticated access token / refresh cookie before MFA success)
- Use Case: Existing deployments, gradual migration of explicit challenge-header enforcement
Enhanced Security Mode (2fa_challenge: true) - RECOMMENDED
- 2FA verification requires cryptographic session binding
- Prevents authentication bypass via stolen
user_id - Blocks OTP phishing and session hijacking attacks
- Security Level: Enhanced with transient session tokens
- Use Case: Production environments requiring high security
How It Works:
- Login (Step 1): User submits username + password
- Backend validates credentials
- Generates MFA Challenge Token (JWT, 5-min expiration)
- Stores token in Redis for single-use validation
- Returns
challenge_tokenin response
- Does not issue authenticated access or refresh-token session artifacts yet
- 2FA Verification (Step 2): User submits OTP
- Frontend includes challenge token in
X-MFA-Challengeheader - Backend validates:
- JWT signature and expiration
- Token subject is
"mfa_challenge" - Token's
user_idmatches requestuser_id - Token exists in Redis (not already used)
- IP/Device changes (logged for monitoring)
- After successful OTP validation, token is deleted (single-use)
- Frontend includes challenge token in
- Result: Authenticated access token and refresh-token cookie are issued only if both password AND OTP are valid in the same session
Security Properties:
- ✅ Session binding: OTP tied to specific authentication session
- ✅ Single-use: Challenge token deleted after verification
- ✅ Time-bound: 5-minute window from login to OTP verification
- ✅ Cryptographically signed: JWT signature prevents forgery
- ✅ Prevents authentication bypass: Cannot use OTP without valid challenge token
- ✅ Prevents session hijacking: Different session cannot reuse challenge token
- ✅ Prevents session puzzling:
challenge_tokencannot be replayed as bearer auth on protected routes or refresh - ✅ Audit trail: IP/Device changes logged for security monitoring
Addresses Security Audit Finding 7.6: "Authentication bypass via One-Time Password" (CVSS 8.1)
Registration Status
pending: Registration initiatedverified: Registration completed
Password Policy
- Minimum length: 8 characters
- Must contain uppercase letters
- Must contain lowercase letters
- Must contain numbers
- Must contain special characters
- Expires after configured
security.password_policy.expiration_days(default 90 days) - Maximum failed attempts: 5
- Reuse of recent passwords is blocked per
security.password_policy.history_count
Password Expiry Status (password_status)
Self-read user responses may include a password_status object so clients can show in-app expiry notices without exposing other users' password metadata. The API omits this field unless user.id equals the authenticated caller's ID, so list and get-by-id responses cannot leak another user's expiry state (IDOR).
| Endpoint | password_status present? |
|---|---|
GET /v1/users/{id} (self) | Yes |
GET /v1/users/{id} (other user) | No — omitted |
GET /v1/users | Only on the authenticated user's own row in the array |
{
"password_status": {
"expires_at": "2026-09-02T12:00:00Z",
"days_until_expiry": 7,
"expired": false,
"change_recommended": true
}
}change_recommendedistruewhen0 < days_until_expiry <= max(security.password_policy.warning_days).- Expired passwords are rejected at login (
auth.password_expired); change viaPUT /v1/users/{id}/passwordor the reset flow.
Endpoints
User Registration
Initiate Registration
POST /v1/users/initiate-registration
Start user registration process.
Request Body:
{
"credential_type": "email",
"credential_value": "user@example.com",
"password": "SecureP@ss123",
"terms_accepted": true,
"privacy_policy_accepted": true
}Response:
{
"message": "OTP sent to user@example.com",
"user_id": "uuid"
}Verify Registration
POST /v1/users/verify-registration
Verify registration OTP.
Request Body:
{
"user_id": "uuid",
"credential_type": "email",
"otp": "123456"
}User Management
Create User
POST /v1/users
Create a new user.
Request Body:
{
"name": "John Doe",
"description": "System user",
"password": "SecureP@ss123",
"active": true,
"lang": "en",
"mfa_mode": "off"
}Create User with Persona
POST /v1/users/persona
Create user with associated persona.
Request Body:
{
"user": {
"name": "John Doe",
"password": "SecureP@ss123",
"active": true,
"lang": "en",
"mfa_mode": "off"
},
"persona": {
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"nationality": "USA"
},
"initiate_registration_input": {
"credential_type": "email",
"credential_value": "john@example.com",
"password": "SecureP@ss123",
"terms_accepted": true,
"privacy_policy_accepted": true
}
}Get User
GET /v1/users/{id}
Retrieve a user by ID. Response fields mirror the safe user read model plus totp_enabled.
When the path id matches the authenticated user, the response also includes password_status (see Password Expiry Status above). Reading another user's record omits password_status.
Response (self-read excerpt):
{
"id": "uuid",
"name": "John Doe",
"active": true,
"totp_enabled": false,
"password_status": {
"expires_at": "2026-09-02T12:00:00Z",
"days_until_expiry": 7,
"expired": false,
"change_recommended": true
}
}List Users
GET /v1/users
List users visible to the caller. Each array entry uses the same read model as Get User. Only the row whose id matches the authenticated user includes password_status; all other entries omit it.
Credential Management
Add Credential
POST /v1/users/credentials
Add new credential to user.
Request Body:
{
"user_id": "uuid",
"type": "email",
"value": "user@example.com",
"send_otp": true
}Validate Credential
POST /v1/users/credentials/validate
Validate credential with OTP.
Request Body:
{
"user_id": "uuid",
"cred_id": "uuid",
"otp": "123456"
}Update Credential
PUT /v1/users/credentials/{cred_id}
Update existing credential.
Request Body:
{
"value": "new@example.com",
"preferred": true,
"active": true,
"metadata": {
"verified_at": "2024-03-21T10:00:00Z"
}
}Password Management
Change Password
PUT /v1/users/{id}/password
Change user password.
Request Body:
{
"current_password": "OldP@ss123",
"new_password": "NewP@ss123"
}Request Password Reset
POST /v1/users/request-password-reset
Request a password reset link for an email or phone credential. Returns a generic success response when the credential is unknown to prevent enumeration.
Request Body:
{
"credential_type": "email",
"credential_value": "user@example.com"
}Error Responses:
400 Bad Request: Invalid credential type or malformed input429 Too Many Requests: IP rate limit exceeded (users_m.password_reset_rate_limit_exceeded; includesretry_after,max_attempts, andwindowin error params)
Reset Password
POST /v1/users/reset-password
Complete a password reset using the token from the reset email.
Request Body:
{
"token": "reset_token",
"new_password": "NewP@ss123"
}Error Responses:
400 Bad Request: Invalid or expired token, password policy violation429 Too Many Requests: IP rate limit exceeded (users_m.password_reset_rate_limit_exceeded; invalid tokens also increment the failed-attempt window)503 Service Unavailable: Rate-limit cache unavailable on completion path (fail closed)
Rate Limiting (password reset endpoints):
- Shared IP counters for
request-password-resetandreset-password(isolated from activation limits) - Layer 1: max attempts per IP per 15 minutes (
security.activation_rate_limiting.max_attempts_per_15min, default 5) - Layer 2: max failed reset attempts per IP per hour (
security.activation_rate_limiting.max_failed_attempts_per_hour, default 10) - Toggle:
security.activation_rate_limiting.enabled - Request path fails open when cache is unavailable; completion path fails closed with
503
User Roles
Get Users by Role ID
GET /v1/users/by-role/{role_id}
Get all users assigned to a specific role by the role's UUID.
URL Parameters:
role_id: UUID of the role
Query Parameters:
page: Page number (optional, for pagination)page_size: Number of users per page (optional, for pagination)
Response:
// Without pagination
[
{
"id": "uuid",
"name": "John Doe",
"description": "Account manager",
"active": true,
"lang": "en",
"mfa_mode": "email"
},
// ...more users
]
// With pagination
{
"users": [
{
"id": "uuid",
"name": "John Doe",
"description": "Account manager",
"active": true,
"lang": "en",
"mfa_mode": "email"
},
// ...more users
],
"total_count": 42,
"page": 1,
"page_size": 10
}Get Users by Role Name
GET /v1/users/by-role-name/{role_name}
Get all users assigned to a specific role by the role's name.
URL Parameters:
role_name: Name of the role (e.g., "Administrator", "User")
Response:
[
{
"id": "uuid",
"name": "John Doe",
"description": "Account manager",
"active": true,
"lang": "en",
"mfa_mode": "email"
},
// ...more users
]Admin Operations
Invite User
POST /v1/users/invite
Invite regular users to the client application. This endpoint creates a new inactive user and sends an invitation email with an activation link.
Security Requirements:
- Requires valid JWT token with "Internal" role
- Requires Create permission on "users" record type
Request Body:
{
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
}Field Descriptions:
email(required): Email address for the new userfirst_name(optional): User's first namelast_name(optional): User's last name
Response (201 Created):
{
"user_id": "uuid",
"email": "user@example.com",
"status": "pending_activation",
"invite_sent_at": "2026-03-24T10:30:00Z",
"invite_expires_at": "2026-03-24T10:32:00Z"
}Error Responses:
400 Bad Request: Invalid input (missing email, invalid email format)403 Forbidden: User lacks Internal role OR Create permission on users409 Conflict: User with this email already exists500 Internal Server Error: Token generation or email sending failed
Example:
curl -X POST https://api.corebanq.com/v1/users/invite \
-H "Authorization: Bearer {jwt_token}" \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe"
}'Activate User Account
POST /v1/users/activate
Activate invited user account and set initial password. This is a public endpoint (API key auth only) that validates the invitation token from the email. Activation is transactional (password, activation flags, optional metadata merge, RBAC credential grants); if a step fails, prior steps roll back. Recommended: call /v1/users/verify-invitation first, then call this endpoint with the same token.
Request Body:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"password": "SecureP@ss123!",
"reference": "",
"terms_accepted": true,
"privacy_policy_accepted": true
}Field Descriptions:
token(required): Invitation token from the email linkpassword(required): Initial password (min 12 characters, must meet password policy)reference(optional): External reference string persisted on activationterms_accepted(required): Must betrueto activate accountprivacy_policy_accepted(required): Must betrueto activate account
Password Requirements:
- Minimum 12 characters
- Must contain uppercase letter
- Must contain lowercase letter
- Must contain number
- Must contain special character
Response (200 OK):
{
"message": "Account activated successfully",
"redirect_url": "https://app.corebanq.com/login"
}Error Responses:
400 Bad Request: Invalid/expired token, weak password, missing or falseterms_accepted/privacy_policy_accepted409 Conflict: Token already used, user already active422 Unprocessable Entity: Inconsistent server state after validation (for example missing email credential row); error codeusers_m.email_credential_not_found. Seemfa_console_policy.mdin this package.
Verify Invitation Token
GET /v1/users/verify-invitation
Validate an invitation token before activation and determine whether it belongs to a regular or privileged invitation flow. This is a public endpoint (API key auth only).
Query Parameters:
token(required): Invitation token from email link
Response (200 OK):
{
"valid": true,
"invite_type": "internal",
"can_activate": true,
"expires_at": "2026-03-24T10:32:00Z"
}Error Responses:
400 Bad Request: Missing token
Invite Privileged User
POST /v1/users/admin/invite
Invite internal platform users with role assignment. This endpoint creates a new inactive user, grants specified roles, and sends an invitation email. The new user's stored mfa_mode is set from auth.internal_invite.mfa_mode in the auth app-config module (totp, email, or phone), not from the tenant default.
Security Requirements:
- Requires valid JWT token with "Internal" role
- Requires Create permission on "users" record type
Request Body:
{
"email": "newuser@corebanq.com",
"first_name": "John",
"last_name": "Doe",
"role_ids": [
"uuid-role-1",
"uuid-role-2"
],
"department": "Engineering"
}Field Descriptions:
email(required): Email address for the new userfirst_name(required): User's first namelast_name(required): User's last namerole_ids(optional): Array of role UUIDs to grantdepartment(optional): Department or team name
Response (201 Created):
{
"user_id": "uuid",
"email": "newuser@corebanq.com",
"status": "pending_activation",
"invite_sent_at": "2026-03-24T10:30:00Z",
"invite_expires_at": "2026-03-24T10:32:00Z"
}Error Responses:
400 Bad Request: Invalid input (missing required fields, invalid email)403 Forbidden: User lacks Internal role OR Create permission on users409 Conflict: User with this email already exists500 Internal Server Error: Token generation failed
Example:
curl -X POST https://api.corebanq.com/v1/users/admin/invite \
-H "Authorization: Bearer {jwt_token}" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@corebanq.com",
"first_name": "Jane",
"last_name": "Smith",
"role_ids": ["role-uuid-1", "role-uuid-2"],
"department": "Support"
}'Activate Internal User
POST /v1/users/activate-internal
Activate invited internal user account and set initial password. This is a public endpoint (API key auth only) that validates the invitation token from the email. Uses the same transactional activation semantics and compliance requirements as /v1/users/activate.
Request Body:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"password": "SecureP@ss123!",
"reference": "",
"terms_accepted": true,
"privacy_policy_accepted": true
}Field Descriptions:
token(required): Invitation token from the email linkpassword(required): Initial password (min 12 characters, must meet password policy)reference(optional): External reference string persisted on activationterms_accepted(required): Must betrueto activate accountprivacy_policy_accepted(required): Must betrueto activate account
Password Requirements:
- Minimum 12 characters
- Must contain uppercase letter
- Must contain lowercase letter
- Must contain number
- Must contain special character
Response (200 OK):
{
"message": "Account activated successfully",
"redirect_url": "https://admin.corebanq.com/login"
}Error Responses:
400 Bad Request: Invalid or expired token, password doesn't meet requirements, missing or falseterms_accepted/privacy_policy_accepted409 Conflict: Token already used OR user account already active422 Unprocessable Entity: Same as/v1/users/activate(users_m.email_credential_not_foundwhen the email credential row is missing)500 Internal Server Error: Server error during activation
Example:
curl -X POST https://api.corebanq.com/v1/users/activate-internal \
-H "Content-Type: application/json" \
-d '{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"password": "MySecureP@ssw0rd!",
"reference": "",
"terms_accepted": true,
"privacy_policy_accepted": true
}'Flow:
- Admin with Internal role + Create permission invites user via
/v1/users/admin/invite - System creates inactive user, grants roles, sends invitation email
- Invited user receives email with activation link containing token
- User opens verification link, client calls
/v1/users/verify-invitation, then sets password via/v1/users/activate-internal - Account becomes active, user can now sign in
Security Features:
- Tokens are single-use (deleted from cache after activation)
- Tokens expire after configured TTL (default 2 minutes)
- Failed activation attempts are rate-limited
- Passwords are hashed using bcrypt before storage
Resend Invitation (Regular User)
POST /v1/users/{id}/resend-invitation
Regenerate and resend the invitation email for an inactive regular user. This endpoint allows resending the invitation if the original token expired or the email was lost.
URL Parameters:
id(required): UUID of the user to resend invitation to
Request Body: None
Response (200 OK):
{
"user_id": "uuid",
"email": "user@example.com",
"status": "invitation_resent",
"invite_sent_at": "2026-03-24T10:45:00Z",
"invite_expires_at": "2026-03-24T10:47:00Z"
}Error Responses:
400 Bad Request: User is already active, no email credential found403 Forbidden: User lacks Internal role (only internal users can resend invitations)404 Not Found: User does not exist429 Too Many Requests: Rate limit exceeded (cooldown active or max attempts reached)500 Internal Server Error: Token generation or caching failed
Rate Limiting:
- Cooldown Period: 10 minutes between successive resends for the same user (configurable via
users.invite_resend_cooldown_minutes) - Sliding Window: Maximum 3 resends per hour per user (configurable via
users.invite_resend_max_per_hour) - Rate limit error includes retry-after information in response
Example:
curl -X POST https://api.corebanq.com/v1/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resend-invitation \
-H "Authorization: Bearer {jwt_token}"Resend Invitation (Internal User)
POST /v1/users/{id}/admin/resend-invitation
Regenerate and resend the invitation email for an inactive internal user. This endpoint follows the /admin/ pattern for internal user operations.
URL Parameters:
id(required): UUID of the internal user to resend invitation to
Request Body: None
Response (200 OK):
{
"user_id": "uuid",
"email": "admin@corebanq.com",
"status": "invitation_resent",
"invite_sent_at": "2026-03-24T10:45:00Z",
"invite_expires_at": "2026-03-24T10:47:00Z"
}Error Responses:
400 Bad Request: User is already active, no email credential found403 Forbidden: User lacks Internal role (only internal users can resend invitations)404 Not Found: User does not exist429 Too Many Requests: Rate limit exceeded (cooldown active or max attempts reached)500 Internal Server Error: Token generation or caching failed
Rate Limiting: Same rate limits apply as regular user resend:
- Cooldown Period: 10 minutes between successive resends
- Sliding Window: Maximum 3 resends per hour per user
- Both limits share the same cache keys (user-specific, not endpoint-specific)
Example:
curl -X POST https://api.corebanq.com/v1/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890/admin/resend-invitation \
-H "Authorization: Bearer {jwt_token}"Flow:
- User never received invitation OR token expired
- Admin calls resend endpoint for that user
- System validates user is inactive, generates new token
- System checks rate limits (cooldown + sliding window)
- New invitation email sent with fresh activation link
- User receives email and can activate account
Error Handling
All errors follow a standard format:
{
"code": "error_code",
"message": "Error description"
}Error Codes
User Errors
| Code | Description |
|---|---|
| users_m.invalid_user_input | Invalid user input data |
| users_m.name_already_exists | Username already taken |
| users_m.invalid_email | Invalid email format |
| users_m.invalid_phone | Invalid phone number |
| users_m.invalid_mfa_mode | Invalid MFA mode |
| users_m.user_already_exists | User with this email already exists |
| users_m.user_already_active | User account is already active |
Permission Errors
| Code | Description |
|---|---|
| users_m.insufficient_permissions | User lacks required permissions |
| users_m.user_creation_permission_denied | User lacks Create permission on users |
Token Errors
| Code | Description |
|---|---|
| users_m.invalid_token | Invalid or expired invitation token |
| users_m.token_already_used | Invitation token has already been used |
| users_m.token_generation_failed | Failed to generate invitation token |
Credential Errors
| Code | Description |
|---|---|
| users_m.credential_already_validated | Credential already validated |
| users_m.duplicate_credential | Credential already exists |
| users_m.invalid_credential_id | Invalid credential ID |
| users_m.credential_not_found | Credential not found |
| users_m.email_credential_not_found | No email credential found for user |
Rate Limiting Errors
| Code | Description |
|---|---|
| users_m.invitation_resend_cooldown_active | Cooldown period active, must wait before resending |
| users_m.invitation_resend_limit_exceeded | Maximum resend attempts exceeded within sliding window |
Authentication & MFA Errors
| Code | Description |
|---|---|
| auth_m.missing_challenge_token | Authentication challenge token required for 2FA operations (when 2fa_challenge: true) |
| auth_m.invalid_challenge | Invalid or expired MFA challenge token |
| auth_m.challenge_already_used | MFA challenge token has already been used (single-use enforcement) |
| auth_m.invalid_or_expired_otp | Invalid or expired one-time password |
Note: Authentication errors prefixed with auth_m. are defined in the common/auth module and apply across all authentication flows including login, 2FA verification, and TOTP operations.
Implementation Notes
User Invitation System
The invitation system supports two distinct flows for creating users without requiring password upfront:
Regular User Invitation (/v1/users/invite)
- Purpose: Invite users to the client application
- Authorization: Requires JWT + Internal role + Create permission on users
- Activation URL:
{main.application.url}/activate?token={token} - Role Assignment: No roles granted during invitation (assigned after activation)
- Email Template:
user_invite(configured inemail_templates.yaml) - Token Cache Prefix:
user_invite:
Internal User Invitation (/v1/users/admin/invite)
- Purpose: Invite internal platform users to the admin application
- Authorization: Requires JWT + Internal role + Create permission on users
- Activation URL:
{main.application.admin_url}/verify-invitation?token={token} - Role Assignment: Automatically grants Internal role + optional additional roles
- Email Template:
internal_user_invite(includes role listing) - Token Cache Prefix:
internal_user_invite:
Token Security
JWT Structure:
- Subject:
user_invitation(regular) orinternal_user_invitation(internal) - Claims:
user_id,email,invited_by,exp - TTL: Configurable via
auth.access_token_ttl_minutes(default: 2 minutes) - Signing: Uses server's JWT secret key
Single-Use Validation:
- Token stored in Redis cache with prefix + token as key
- On activation, token is validated from cache
- After successful activation, token is deleted from cache
- Subsequent use of same token returns
token_already_usederror
Email Configuration
Email templates are configured in config/samples/email_templates.yaml:
email_templates:
user_invite:
subject:
en: "You're invited to Corebanq"
body:
en: |
Hello {{.FirstName}},
You've been invited to join Corebanq...
{{.ActivationLink}}
This link expires at {{.ExpiresAt}}
internal_user_invite:
subject:
en: "Welcome to Corebanq Platform"
body:
en: |
Hello {{.FirstName}},
You've been invited as an internal user...
Roles: {{range .RoleNames}}{{.}}{{end}}Rate Limiting for Invitation Resends
To prevent abuse of the resend invitation feature, a combined rate limiting strategy is enforced:
Dual Protection Mechanism:
-
Cooldown Period (Prevents rapid successive resends)
- Default: 10 minutes between resends for the same user
- Configurable:
users.invite_resend_cooldown_minutesinmain.yaml - Cache key:
user_invite_cooldown:{user_id} - TTL: Cooldown duration + 1 minute buffer
-
Sliding Window (Prevents sustained abuse)
- Default: Maximum 3 resends per hour per user
- Configurable:
users.invite_resend_max_per_hourinmain.yaml - Cache key:
user_invite_attempts:{user_id} - TTL: 1 hour (rolling window)
Configuration Example:
users:
invite_resend_cooldown_minutes: 10 # Min time between resends
invite_resend_max_per_hour: 3 # Max resends in 1-hour windowRate Limit Response (429 Too Many Requests):
{
"code": "users_m.invitation_resend_cooldown_active",
"message": "In cooling period for 8m 32s, 512 second(s) left before resending invitation",
"params": {
"cooldown_period": "8m 32s",
"wait": 512
}
}or:
{
"code": "users_m.invitation_resend_limit_exceeded",
"message": "Maximum invitation resend limit reached. You can send up to 3 invitations per 1 hour",
"params": {
"max_attempts": 3,
"window": "1 hour"
}
}Implementation Details:
- Rate limits are user-specific (not endpoint-specific): both regular and internal resend endpoints share the same counters
- Cooldown starts immediately after each successful resend
- Attempts counter increments with each resend, resets after 1 hour
- Cache failures are logged but non-fatal (resend proceeds if cache is unavailable)
- Context-aware logging includes
request_idfor traceability
Database Schema
Users Table:
active: Set tofalseduring invitation,trueafter activationemail_verified: Set totrueafter successful activationhashed_password: Set during activation (not during invitation)last_password_change_at: Set to activation timestamppassword_expires_at: Set to activation timestamp + expiration duration
Persona Linking:
- For internal users: First name + Last name stored in
personastable - Linked via
persona_linkstable withrec_type_name = 'users' - For regular users: Persona is optional, created only if names provided
Role Assignment:
- Internal users: Insert into
user_rolestable withrole_id= Internal role UUID - Additional roles (if provided): Inserted as separate
user_rolesrecords - All role assignments done within transaction during user creation
Configuration Requirements
Required config keys in main.yaml:
application:
url: https://app.corebanq.com # Client app URL
admin_url: https://admin.corebanq.com # Admin app URL
auth:
access_token_ttl_minutes: 2 # Invitation token TTL
jwt_secret: "your-secret-key" # JWT signing key
email:
service: "smtp" # Email service (smtp, ses, etc.)
from: "noreply@corebanq.com" # From addressError Messages
All error messages are multi-lingual, defined in config/samples/users_m.yaml:
messages:
user_already_exists:
en: "User with email {{.email}} already exists"
de: "Benutzer mit E-Mail {{.email}} existiert bereits"
fr: "L'utilisateur avec l'email {{.email}} existe déjà"
invalid_token:
en: "Invalid or expired invitation token"
de: "Ungültiges oder abgelaufenes Einladungstoken"
fr: "Jeton d'invitation invalide ou expiré"
token_already_used:
en: "This invitation has already been used"
de: "Diese Einladung wurde bereits verwendet"
fr: "Cette invitation a déjà été utilisée"Testing with Postman
The Postman collection includes 4 new requests:
- Invite User - Test regular user invitation
- Invite Privileged User - Test privileged user invitation with roles
- Activate User Account - Test activation with password
- Activate Privileged User Account - Test privileged activation
Set these Postman environment variables:
{{base_url}}- API base URL{{access_token}}- JWT token with Internal role{{api_key}}- API key for public endpoints{{invitation_token}}- Token from invitation email (for activation tests)
Password Errors
| Code | Description |
|---|---|
| users_m.password_changed | Password successfully changed |
| users_m.otp_error_validating | Error validating OTP |
| users_m.totp_invalid_code | Invalid TOTP code |
Authentication & MFA Security Errors
| Code | Description | When Occurs |
|---|---|---|
| auth_m.missing_challenge_token | MFA challenge token required | 2FA operation without X-MFA-Challenge header when 2fa_challenge: true |
| auth_m.invalid_challenge | Invalid or expired challenge token | JWT signature invalid, expired, or wrong subject type |
| auth_m.challenge_already_used | Challenge token already used | Token not in Redis cache (already used or expired) |
| auth_m.invalid_or_expired_otp | Invalid or expired OTP | OTP validation failed |
Note: Authentication errors (prefixed auth_m.) are defined in common/auth module and shared across authentication endpoints.
Security Features
MFA Session Binding (Authentication Bypass Prevention)
Resolves: Security Audit Finding 7.6 - "Authentication bypass via One-Time Password" (CVSS 8.1 - HIGH)
Status: ✅ FULLY IMPLEMENTED
Overview
The authentication system implements cryptographic session binding for 2FA verification to prevent attackers from bypassing password authentication and accessing accounts using only user_id + OTP.
Configuration Flag: auth.2fa_challenge in the auth app-config module
Security Modes
1. Enhanced Security Mode (2fa_challenge: true) - RECOMMENDED
Requires MFA Challenge Token for all 2FA operations:
- ✅ Prevents authentication bypass attacks
- ✅ Binds OTP verification to successful password authentication
- ✅ Single-use token enforcement
- ✅ 5-minute session window
- ✅ Cryptographic JWT signature validation
Flow:
Login → Generate Challenge Token → Store in Redis → Return to Client
↓
Client submits OTP with Challenge Token in X-MFA-Challenge header
↓
Validate: JWT signature + user_id match + Redis cache + expiration
↓
Success: Issue access/refresh tokens + Delete challenge token2. Backward Compatible Mode (2fa_challenge: false) - LEGACY
OTP-only validation without session binding:
- ⚠️ Vulnerable to authentication bypass if attacker obtains
user_id - ⚠️ No protection against session hijacking
- ✅ Maintains compatibility with older clients
- ✅ Suitable for gradual migration
Attack Scenarios Prevented
Scenario 1: Stolen user_id + Phished OTP
Attacker has: user_id="abc-123", otp="123456"
Attacker attempts: POST /v1/verify-2FA with stolen data
Result (2fa_challenge: true): ❌ BLOCKED - Missing valid challenge token
Result (2fa_challenge: false): ✅ SUCCESS - Vulnerability exploitedScenario 2: Session Hijacking
Attacker intercepts: login response with challenge_token
Victim receives: OTP code
Attacker attempts: POST /v1/verify-2FA with stolen challenge + guessed OTP
Result: ❌ BLOCKED - OTP validation fails + rate limiting appliesScenario 3: Replay Attack
Attacker captures: Used challenge_token from network traffic
Attacker attempts: Reuse challenge token with new OTP
Result: ❌ BLOCKED - Token deleted from Redis after first useImplementation Details
Challenge Token Structure (JWT):
{
"user_id": "abc-123-def",
"ip_address": "192.168.1.1",
"device_id": "device-uuid",
"issued_at": "2026-03-24T10:00:00Z",
"sub": "mfa_challenge",
"exp": 1711274700,
"iat": 1711274400,
"jti": "unique-token-id"
}Validation Steps:
- Extract
X-MFA-Challengeheader from request - Validate JWT signature (HMAC-SHA256)
- Verify
subclaim equals"mfa_challenge" - Verify
expnot expired (5 minutes) - Verify
user_idin token matches request - Check token exists in Redis (key:
mfa_challenge:{token}) - Log IP/Device changes (monitoring, not blocking)
- After OTP success: Delete token from Redis
Endpoints Affected:
POST /v1/verify-2FA- RequiresX-MFA-Challengeheader (when enabled)POST /v1/auth/totp/setup- Requires challenge token inAuthorizationheader (when enabled)POST /v1/auth/totp/verify- Requires challenge token inAuthorizationheader (when enabled)
Frontend Integration:
// Step 1: Store challenge token from login response
const loginResponse = await api.post('/v1/authenticate/admin', { username, password });
const challengeToken = loginResponse.data.challenge_token;
// Step 2: Include in header for 2FA verification
await api.post('/v1/verify-2FA',
{ user_id, otp },
{ headers: { 'X-MFA-Challenge': challengeToken } }
);Migration Guide
Step 1: Verify Implementation
- ✅ Backend version supports challenge tokens
- ✅ Frontend sends
X-MFA-Challengeheader - ✅ Redis cache available
Step 2: Enable in Configuration
# auth app-config module
2fa_challenge: trueStep 3: Test Complete Flow
- Login with password → Receive challenge_token
- Submit OTP with X-MFA-Challenge header → Success
- Attempt OTP without header → 400 Bad Request
- Attempt with used token → 401 Unauthorized
Step 4: Monitor
- Check Redis cache hit rates
- Review logs for IP/Device change warnings
- Monitor authentication failure rates
Compliance & Audit
Standards Met:
- ✅ OWASP recommendation for 2FA session binding
- ✅ NIST SP 800-63B guidelines for multi-factor authentication
- ✅ Iterasec Security Audit Finding 7.6 remediated
Audit Trail:
- All challenge token validations logged with
request_id - IP/Device changes logged for security monitoring
- Token generation/validation errors logged for incident response
Risk Assessment:
- Before Implementation: HIGH (CVSS 8.1) - Authentication bypass possible
- After Implementation (Enabled): LOW - Requires stealing both challenge token AND OTP within 5 minutes
- After Implementation (Disabled): HIGH - Same risk as before (backward compatibility)
TOTP Enforcement for Internal Users
Purpose: Mandatory TOTP authentication for privileged accounts provides stronger security than email/phone OTP.
Automatic Enforcement:
- All new internal/privileged users are created with
mfa_mode = "totp"by default - System automatically detects if TOTP is configured during login
- Users without TOTP setup receive
credential_type: "totp_setup_required"response - Frontend redirects to TOTP setup flow before completing authentication
Setup Flow:
- Internal user activates account and attempts login
- API detects
mfa_mode = "totp"buttotp_enabled = false - API returns challenge token with setup-required status
- User generates TOTP secret and scans QR code via
/v1/totp/setup - User verifies TOTP code via
/v1/totp/verifyto complete setup - TOTP is enabled (
totp_enabled = true) - Subsequent logins prompt for TOTP code
Security Benefits:
- ✅ Phishing resistance - harder to steal than email/SMS OTP
- ✅ No external dependencies - works offline
- ✅ Industry standard - compatible with Google Authenticator, Authy, 1Password
- ✅ Mandatory for administrators - ensures privileged accounts have strong 2FA
- ✅ Recovery codes - users can regain access if device is lost
Existing Users: Update existing internal users to TOTP mode via SQL migration script:
UPDATE users.users
SET mfa_mode = 'totp'
WHERE id IN (
SELECT DISTINCT ur.user_id
FROM users.user_roles ur
JOIN users.roles r ON ur.role_id = r.id
WHERE r.name IN ('Internal', 'Administrator')
)
AND mfa_mode != 'totp';Implementation Locations:
- TOTP endpoints:
/connectors/totp/(setup, verify, disable, recover)
Rate Limiting for Invitation Resends
Purpose: Prevent abuse of invitation resend functionality through dual protection mechanism.
Dual Protection Strategy:
1. Cooldown Period (Prevents rapid successive resends)
- Default: 10 minutes between resends for the same user
- Configuration:
users.invite_resend_cooldown_minutesinmain.yaml - Cache key:
user_invite_cooldown:{user_id} - Blocks immediate retries after sending invitation
2. Sliding Window (Prevents sustained abuse)
- Default: Maximum 3 resends per hour per user
- Configuration:
users.invite_resend_max_per_hourinmain.yaml - Cache key:
user_invite_attempts:{user_id} - Prevents sustained attack over time with multiple resend attempts
Configuration Example:
users:
invite_resend_cooldown_minutes: 10 # Min time between resends
invite_resend_max_per_hour: 3 # Max resends in 1-hour windowRecommended Profiles:
- Strict:
cooldown=15min, max=2/hour- High security environments - Balanced:
cooldown=10min, max=3/hour- Default (production) - Lenient:
cooldown=5min, max=5/hour- Development/testing
Rate Limit Behavior:
- Limits are user-specific (not endpoint-specific)
- Both regular and internal resend endpoints share the same counters
- Cache failures are logged but non-fatal (service remains available)
- HTTP 429 response includes retry-after information in error params
Error Responses:
users_m.invitation_resend_cooldown_active- Cooldown period still active (includes seconds remaining)users_m.invitation_resend_limit_exceeded- Max attempts exceeded within sliding window
Implementation:
- Context-aware logging includes
request_idfor traceability
Security Benefits:
- ✅ Prevents email bombing attacks
- ✅ Prevents token enumeration attempts
- ✅ Reduces email service costs from abuse
- ✅ Graceful degradation if cache unavailable
- ✅ Clear user feedback with retry-after guidance
Additional Security Documentation
For complete security audit findings and other security features:
- Security Audit: See
/test-data/security/todo.mdfor all security audit findings