Description
Purpose and use
Users represent the people who can access CoreBanq, receive operational messages, approve work, manage customers, or use customer-facing channels. The module covers credentials, invitations, MFA, activation, password expiry, avatar data, and user context.
Who uses this. Administrators, security operations, support teams, customer operations, and end users use user workflows for registration, login, invitations, recovery, and access review.
How it works. A user can have one or more credentials, security state, customer links, roles, MFA settings, and password-policy status. Authentication and profile flows use these records to decide whether access should be granted.
What users do. Users register, accept invitations, authenticate, complete MFA, reset or change passwords, and manage profile information. Administrators invite, activate, resend invitations, and review user status.
Outcomes and side effects. User changes can grant or remove access, trigger notifications, update audit history, and change which customer or operational records are visible. They do not post ledger entries.
Related manuals: Roles, RBAC, Profile, Password expiration reminders.
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, and that notice is rate limited so repeated probing cannot flood its recipient. It is sent by the three flows that attempt to claim a credential — registration, adding one to a profile, and changing one — and not byresend-otp, which claims nothing: the owner of a confirmed address reaches that path by asking for their own code again, and would be told somebody had tried to register their address.
Known residual: response time still separates the two cases
The answers match; the latency does not. initiate-registration on a free address runs the
registration transaction and waits for the transport to accept the message, both inside the request.
The held branch writes nothing and issues a cache-only decoy, and after the first probe of an address
the owner notice is suppressed for security.duplicate_credential_notice.min_interval, so the held
branch gets faster while the free one keeps paying for a transaction and a round trip. The gap is
large enough to read from a single request, which leaves the same question answerable off the clock.
What bounds it today is the per-IP limiter on both routes, not the uniform answer. Closing it means taking registration OTP delivery off the request goroutine on both paths, which needs a worker with retries and undelivered-message monitoring rather than a fire-and-forget goroutine — tracked separately. Padding the held branch was considered and rejected: the floor would have to sit above the slowest transport round trip to work at all, and every probe would hold a request open for it.
User Registration
Initiate Registration
POST /v1/users/initiate-registration
Start user registration process.
Both consent fields must be true. The API records terms_accepted_at and
privacy_accepted_at when it accepts the registration request, including when
a pending registration is restarted.
A credential value that cannot be delivered to is refused with 400 before any row is
written or resumed: users_m.invalid_email, users_m.invalid_email_spaces or
users_m.invalid_email_length for an email, users_m.invalid_phone for a phone number.
No account exists after this refusal, and retrying — here or through resend-otp — cannot
succeed until the value itself changes.
The registration row is committed before the one-time code is issued, so the
OTP service's own refusals reach this endpoint: 429 otp_m.cooling_period_active
while the credential is in its cooling-off period, and 500 otp_m.failed_send_otp,
otp_m.cache_error or otp_m.generation_failed when a code for a deliverable value
could not be issued or delivered. In both cases the account exists — retry through
POST /v1/users/resend-otp rather than repeating registration.
Send pacing is the exception. A repeated submit inside otp.resend.min_interval, or one for a
credential that has spent its window, issues no second code but answers the ordinary 200 ack
while the credential still holds a usable one: the row is committed either way, and the caller is
holding the code from the submit they repeated. The ack is the same opaque message every other
outcome gives — a distinct one would say that this credential has a live code. That holds whether or not a code survived: the ack is
the same one every other outcome gives, and a refusal only a known credential can provoke would
say that the address is already registered. The lockout, a delivery failure and an unreadable store
still answer as they did.
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.
Both consent fields under initiate_registration_input must be true. The
created user is active immediately, and the API records both consent timestamps
in the same user record.
Request Body:
{
"user": {
"name": "John Doe",
"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.
The value must be well formed for its type — 400 otherwise — and must not already be validated
on another credential, including one belonging to a different user: that answers 409
users_m.duplicate_credential. Deactivating a credential does not release its value, so an
address stays taken until its credential is deleted. Creating a credential for another user requires
the caller to be a scoped administrator in the target's customer context; anything else answers
403, and 404 when the target user does not exist.
With send_otp: true this endpoint issues a code, so the OTP refusals apply here as well. A request
that arrives while the credential's cooling-off period is running answers 429
otp_m.cooling_period_active — it previously reported 500. One that arrives before send pacing allows the next
code issues none, and answers 201 with the credential while the credential still holds a usable
code — it is stored either way, and the caller can submit the code it already has. That answer says
so: code_sent is false and retry_after carries the seconds until the next code may be asked
for. Only where no code is left does it answer 429 otp_m.resend_too_soon or
otp_m.resend_limit_exceeded.
The attempt budget is shared per
credential, so failures recorded during registration or login can refuse this call. A delivery
failure answers 500 otp_m.failed_send_otp, and an unreadable OTP store 500
otp_m.cache_error.
The credential row is created before the code is sent, so a refusal leaves it stored and unvalidated. Retrying the same request is safe: the pending row is reused and a new code is requested for it, rather than a second copy being created.
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.
This endpoint answers in the same shape as verify-registration — message, status, and
both retry_after and wait on a cooling response, with no code field — so branch on status,
using the same table as that endpoint. An already-validated credential is refused with 400 and
status: "already_validated" before the code is checked.
One address can hold only one validated credential, and that is enforced by a partial unique index
on (type, LOWER(value)) WHERE validated rather than by a check in front of the write — so it holds
for every write path, including the ones that do not check at all. A code that is correct but arrives
after the address was validated elsewhere answers 409 with status: "duplicate", and the
credential stays unvalidated. That state is terminal for it: the address belongs to another
credential, so a fresh code cannot change the outcome. Deactivating a credential does not release its
value.
The credential is fetched under its owner. A caller validates their own credential, or — as a scoped administrator in the target's customer context — one belonging to a user in that context; anything else answers 403, and 404 when the target user does not exist. A credential that is not the owner's does not identify itself: the owner-scoped fetch fails and the refusal reaches the caller as the same generic 500 as any other verification failure. A successful validation switches the MFA mode of the credential's owner, not of the caller. That switch counts only credentials that are both validated and active, and takes the mode from the most recently created of them: a credential that was never confirmed, or that the owner deactivated, neither enables MFA nor names its channel.
Request Body:
{
"user_id": "uuid",
"cred_id": "uuid",
"otp": "123456"
}Update Credential
PUT /v1/users/credentials/{cred_id}
Update existing credential.
A value that differs from the one the credential already holds must not be validated on another
credential, including one belonging to a different user: that answers 409
users_m.duplicate_credential. Uniqueness counts validated credentials only, so an unvalidated
credential carrying the same value is not a conflict, whoever owns it. Add Credential reuses
such a row when it belongs to the same owner and a code was issued but never redeemed. Surrounding
whitespace is trimmed from the value before it is compared and stored, and the check is
case-insensitive for every credential type. Deactivating a credential does not release its value:
the count ignores active and credentials are not soft-deleted, so a validated row keeps its
address until it is deleted.
A caller without update on the credential record is refused with 403 before any of this runs.
A value that is empty after trimming is treated as absent: the credential is left untouched and no
code is issued.
Re-submitting the value the credential already holds is not a change and is not checked against that
rule, but it is still a value write: the credential is un-validated, loses preferred, and a fresh
code is sent, exactly as a real change would be.
Changing value also re-issues a code for the new value, so this endpoint carries the same OTP
refusals as Add Credential: 429 otp_m.cooling_period_active while the credential is in its
cooling-off period, 500 otp_m.failed_send_otp on a delivery failure, and 500
otp_m.cache_error when the OTP store cannot be read. Send pacing behaves as it does there too:
no second code is issued, but the update answers 200 while the credential still holds a usable
one, with code_sent: false and the retry_after to wait, and only 429
otp_m.resend_too_soon or otp_m.resend_limit_exceeded where none is left. The stored value
is already updated in either case, so the retry is a plain resend rather than a second edit.
Both credential endpoints carry code_sent and retry_after on every answer that could have issued
a code, so a client seeds its resend countdown from the server and never promises a message that was
not sent. code_sent is false where none was asked for, too. Where pacing is what held the code
back, message carries auth_m.otp_previously_sent — the same text the login answers with — on
both endpoints.
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).retry_after,max_attemptsandwindowareAppErrorparams that interpolate the i18n template;StdResponsedoes not serialise them, so the body carries noparamsobject andretry_afteris readable only from the renderedmessage
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"
}Carrying a verification_token turns this into signatory or team-invite acceptance, which confirms
the user's e-mail. One address can hold only one confirmed credential, so if it was confirmed on
another account in the meantime the call answers 409 users_m.duplicate_credential and the whole
acceptance — credential, role, customer link — rolls back untouched.
Error Responses:
400 Bad Request: Invalid or expired token, password policy violation409 Conflict: The e-mail being confirmed is already validated on another account (users_m.duplicate_credential, invitation-acceptance path only)429 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.
Activation confirms the invited e-mail, and one address can hold only one confirmed credential. If
that address was confirmed on another account between the invitation and this call, activation
answers 409 users_m.duplicate_credential and nothing is written. Retrying cannot succeed: the
address belongs to another account, so the invitation has to be reissued for an address that is
free, or the two accounts merged. The guard that normally prevents this compares the stored user
name exactly, so it can be passed by a different spelling of the same address.
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, including the 409 users_m.duplicate_credential refusal when the invited address is already confirmed on another account.
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 "X-API-Key: {api_key}" \
-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: 5 minutes between successive resends for the same user (configurable via
security.invitation_rate_limiting.resend_cooldown_minutes) - Fixed Window: Maximum 3 resends per hour per user, starting from the first resend (configurable via
security.invitation_rate_limiting.max_resends_per_hour) - Can be disabled entirely via
security.invitation_rate_limiting.enabled - 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: 5 minutes between successive resends
- Fixed Window: Maximum 3 resends per hour per user, starting from the first resend
- 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 + fixed hourly window)
- New invitation email sent with fresh activation link
- User receives email and can activate account
Error Handling
All errors are the shared apireply envelope — six fields, not two:
{"status": 400, "message": "Invalid user input", "code": "users_m.invalid_user_input", "class": "validation"}message is the resolved i18n template for code, not a description of what the caller did
wrong — branch on code. code, class, retryable and details are stamped only outside the
2xx range, so a success body never carries them. ClassForStatus has no empty branch: 400 and
422 are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry
retryable: true; everything else, 500 included, is business.
Refusals the middleware writes before the handler runs
On the bearer routes, produced by the root router's chain —
auth.RateLimitMiddleware when it is mounted, then health.LifecycleMiddleware, then
auth.Middleware:
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | No bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup |
401 | permission denied | auth.RateLimitMiddleware's authenticated branch. findMatchingEndpoint () raises errs.New("permission denied").WithCode(401) when the caller's cached endpoint limits contain no entry matching this method and path, and handleRateLimitError answers it through Unauthorized401 with the error — so code carries that English sentence, not a MsgCode. Bearer routes only: the anonymous branch never reaches rbac.CanCallAPI |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missing | The licence branch. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but written to the licence-error context by no code path, so it never reaches a client |
429 | rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit | auth.RateLimitMiddleware, which is mounted only when the AppConfig flag rate_limits.rate_limits_switcher is true — with it off the middleware is absent from the chain and this status is unreachable. These three codes come from the authenticated branch only: checkAuthorization must parse the bearer token before the middleware reaches rbac.CanCallAPI. Not common.too_many_requests — that key is only the fallback stamped when the helper is called with no AppError. Separate from any per-route throttle a handler applies itself |
429 | Global rate limit exceeded | The same middleware's anonymous branch, taken whenever the bearer token is missing or does not parse. It builds the refusal with errs.New on a free-text string rather than a MsgCode, and errs.New stores its argument as the key — so code carries that English sentence and no i18n template resolves it. A cache failure on the counter answers the same way, so this 429 does not prove an overrun |
500 | common.server_error | The same middleware's default branch: fetchUserRoles, cacheUserLimits, checkAndIncrementLimit and findMatchingEndpoint return 500-coded errors (a failing roles query, an unwritable limit cache, a failed Redis key scan), and handleRateLimitError answers them with InternalServerError500 called with no AppError, so the body carries the helper's default code rather than the one the failure raised |
503 | auth_m.internal_server_error | The auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup. The code is the constant local to package auth, not errs.MsgInternalServerError (common.server_error) — the two share a Go name in different packages |
Which limiter branch a request takes is decided by the Authorization header, not by the route.
auth.RateLimitMiddleware sits on the root router, ahead of dispatch, and checkAuthorization
inspects only that header. On the API-key routes the normal caller sends no bearer token and takes
the anonymous branch, so of this table only the anonymous 429 and the shutdown 503 apply. But
nothing stops a caller from sending both headers — a client with a global auth interceptor routinely
does — and such a request takes the authenticated branch even on an API-key route, so the three
rate_limits_m.* codes, the permission denied 401 and the 500 all become reachable there.
Their own 401/403 remain the two API-key codes in the table above: auth.Middleware genuinely
never runs on these eight, whatever headers arrive.
Six routes throttle themselves as well, inside the handler, and that throttle is independent of
the middleware: it is governed by security.activation_rate_limiting.enabled (default true) and
keeps answering 429 with rate_limits.rate_limits_switcher off. initiate-registration,
activate, activate-internal and verify-invitation answer
users_m.activation_rate_limit_exceeded; request-password-reset and reset-password answer
users_m.password_reset_rate_limit_exceeded. Both limiters count two windows — every call from the
IP over 15 minutes, and the failures recorded for it over an hour. retry_after, max_attempts and
window are AppError params that interpolate the i18n template; StdResponse does not serialise
them, so the body carries no params object.
reset-password alone fails closed on a cache error: checkPasswordResetCompleteRateLimit is
the handler's first act, and an unreachable Redis makes it answer 503
users_m.internal_error — the envelope, on an API-key route. The other five fail open and let the
request through.
The graceful-shutdown 503 reaches every route in the module, and it is not the envelope.
health.LifecycleMiddleware is mounted with r.Use on the root router, ahead of both
authentication chains, and writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} — with no
status, code, class or retryable field, and a fixed English message that
Accept-Language does not translate. A client must branch on the shape before the code.
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 the current fixed hourly window |
| otp_m.resend_too_soon | A code was issued for this credential less than otp.resend.min_interval ago |
| otp_m.resend_limit_exceeded | The credential has spent otp.resend.max_per_window codes inside otp.resend.window |
| otp_m.cooling_period_active | The credential is locked out after too many wrong codes |
| users_m.activation_rate_limit_exceeded | Per-IP activation limiter in the handler, on initiate-registration, activate, activate-internal and verify-invitation |
| users_m.password_reset_rate_limit_exceeded | Per-IP limiter in the handler, on request-password-reset and reset-password |
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}}OTP Attempt Budget and Cooling-Off
Failed OTP verifications are counted per credential, not per issued code. The count, the cooling deadline and the escalation step are all recorded against the credential itself.
A resend does not reset the budget. Requesting a new code issues one, but the failures already
recorded against that credential stay. Once otp.max_attempts (default 3) is reached a cooling-off
period opens, and both submitting another code and requesting a resend are refused until it
elapses. Each cooling-off period is longer than the one before it — one minute, then two, then
three — up to otp.cooling.max (default one hour), which every later lockout in the same window
holds at. That ceiling covers the e-mail and phone paths described here; TOTP and TOTP-recovery
lockouts escalate on their own schedule. Requesting a resend does not shorten the next one. The escalation is cleared by a
successful verification, and otherwise expires otp.ttl.failed_attempts (default 24 hours) after
the most recent lockout — each lockout rewrites the index with a fresh TTL, so it outlives the
attempt counter, which is cleared at the same moment.
The attempt counter runs on a fixed window instead: its otp.ttl.failed_attempts starts at the
first failure and later failures do not extend it, so the budget refills a fixed 24 hours after the
first failed attempt rather than 24 hours after the last.
The two endpoints answer in different shapes, so branch per endpoint.
POST /v1/users/verify-registration returns its own envelope — message, status, and wait on a
cooling response. There is no code field on this path; the counts and the remaining time are
interpolated into message, and status is the field to branch on:
| Situation | Status | status field |
|---|---|---|
| Wrong code, attempts left | 400 | invalid |
| Code expired, or already consumed by a concurrent submission | 400 | expired |
| Credential is already validated | 400 | already_validated |
| Correct code, but the address is validated on another credential | 409 | duplicate |
| Wrong code, budget exhausted | 429 | cooling (with retry_after in seconds, and wait beside it) |
| Anything else | 500 | error |
Two rejections happen before verification runs and answer the standard error envelope with a
code instead of this shape: an unknown user, and no credential matching the submitted
credential_type / credential_value (users_m.credential_not_found). Check for status first
and fall back to code.
POST /v1/users/resend-otp returns the standard error envelope, so code is available there:
| Situation | Status | code |
|---|---|---|
| Resend during an active cooling period | 429 | otp_m.cooling_period_active |
| Resend before the minimum interval elapsed | 429 | otp_m.resend_too_soon |
| Resend after the credential spent its window | 429 | otp_m.resend_limit_exceeded |
| Resend for an already-verified credential | 400 | users_m.credential_already_validated |
| No user matches the credential | 400 | auth.invalid_credentials |
| Stored credential cannot be delivered to | 400 | users_m.invalid_email, users_m.invalid_email_spaces, users_m.invalid_email_length or users_m.invalid_phone |
The deliverability refusal runs before the send, so a stored value the mail or SMS hop could
never accept answers 400 here instead of surfacing as 500 otp_m.failed_send_otp. Resending
cannot fix it; the credential value itself has to change.
A code the server could not read at all — the cache was unreachable, or the client hung up before
the read finished — is refused with 500 and does not spend an attempt: it was never compared
against anything, and counting it would let an outage walk a user into a lockout the resend no
longer clears. verify-registration reports it as status: "error" per the table above;
resend-otp reports otp_m.cache_error.
A successful verification clears the attempt counter and the escalation index, so a legitimate user who eventually enters the right code starts clean. It cannot cut an active cooling-off period short, because no code is accepted while one is running.
Because the budget is recorded against the credential and not the flow, it is shared across every path that sends an OTP to the same email or phone — registration, login 2FA and credential verification. Failures accumulated in one flow count against the others.
That sharing is why verification refuses an already-validated credential before checking the
code, with 400 and status: "already_validated". verify-registration runs on the API key
alone and resolves the user from the submitted credential value, so without that guard anyone who
knows an active user's email could spend their budget and leave them in a cooling-off period that
blocks their next login. resend-otp has always refused a validated credential the same way.
Note that the service itself raises otp_m.otp_invalid with 401; the registration handler
rewrites that to 400 with the envelope above, so the service-level code is not the API contract.
Pacing OTP Sends
The attempt budget above bounds how often a code may be guessed. A separate guard bounds how often one may be issued. Without it a burst of resends delivered one message per request.
The guard sits in the OTP service itself, so it covers every entry point that issues a code —
registration, resend-otp, login 2FA and credential confirmation — and all of them draw on one
budget per credential.
Dual Protection Mechanism:
-
Minimum Interval (Prevents rapid successive sends)
- Default: 30 seconds between codes for the same credential
- Configurable:
otp.resend.min_interval - Cache key:
otp:{method}:{credential}:send_gate - Claimed atomically, so exactly one request of a burst issues a code
- Refusal: 429
otp_m.resend_too_soon
-
Fixed Window (Prevents sustained abuse)
- Default: Maximum 5 codes per 15 minutes per credential
- Configurable:
otp.resend.max_per_window,otp.resend.window - Cache key:
otp:{method}:{credential}:send_window - TTL starts at the first send in the window (fixed window, not rolling); the window resets once it elapses
- Refusal: 429
otp_m.resend_limit_exceeded
These three keys tune how strict pacing is; there is no switch that turns it off. A value that
is missing, unparseable or non-positive falls back to its default, so a typo cannot leave code
issuance unbounded either. If pacing proves too strict for a flow, shorten min_interval or raise
max_per_window rather than looking for a toggle. The three values are read on each send, so an
app-config change reaches running pods without a restart: a raised cap applies to the next send,
while a changed interval or window sizes only the gates and windows opened after it.
A window slot is spent by an issued code, not by a request. The cap is read before the code is
generated and counted only after it is stored, so a request that fails to generate or store one
leaves the window untouched — otherwise a cache that dropped writes could burn the whole window and
lock the credential out for the rest of it without ever sending anything. Delivery is different: it
runs after the code is stored, so 500 otp_m.failed_send_otp keeps the slot and leaves the minimum
interval running, and a retry answers 429 otp_m.resend_too_soon until it elapses. That asymmetry
is deliberate — a caller can provoke a delivery failure, but not a cache write failure. The code
itself stays verifiable either way — a provider that took the message and then failed to answer did
deliver it — but where no channel confirmed taking it, nothing treats it as one the user holds. Where
the OTP webhook took the code that reservation does not apply: its consumer may be holding it.
A successful verification releases the minimum interval along with the code it consumed, matching the gate against the token it held before the code was spent so that an interval a later send armed in the meantime is left running. The interval exists to stop a second code landing on top of one the user is still holding; once that code has been used there is nothing left to protect, and keeping it would refuse the next send for the rest of the interval with no code to offer in its place. The window is not released — that one caps how much a credential may ask for, which a verification does not change.
Unlike the cooling-off period, this guard fails open. It paces delivery rather than bounding guesses, so an unreachable cache must not stop registration and login from issuing codes.
Configuration Example:
# app-config module: otp
resend:
min_interval: 30s # Min time between codes for one credential
max_per_window: 5 # Max codes per window
window: 15m # Window lengthRefusal (429 Too Many Requests):
{
"status": 429,
"code": "otp_m.resend_too_soon",
"message": "Another code cannot be sent for 24 more second(s); please try again shortly",
"class": "temporary",
"retryable": true,
"retry_after": 24
}retry_after carries whole seconds, rounded up, and is mirrored in the Retry-After header. Every
OTP rate-limit refusal carries it, otp_m.cooling_period_active and otp_m.max_attempts_reached
included, and so do the successful answers that issue a code — POST /v1/users/resend-otp and the
MFA challenge from POST /v1/authenticate — so a client starts its countdown from the server's
value instead of a build-time constant. The invitation-resend limits below do not carry it yet, so
treat the field as optional on a 429. An answer that names no interval omits it rather than
reporting a zero.
A wrong code that has not exhausted the budget names no interval at all. Clients rely on that to leave a running resend countdown alone: a mistyped digit must not cost a fresh wait.
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: 5 minutes between resends for the same user
- Configurable:
security.invitation_rate_limiting.resend_cooldown_minutesinsecurity.yaml - Cache key:
user_invite_cooldown:{user_id} - TTL: Exactly the configured cooldown duration (no extra buffer)
<= 0disables the cooldown check
-
Fixed Window (Prevents sustained abuse)
- Default: Maximum 3 resends per hour per user
- Configurable:
security.invitation_rate_limiting.max_resends_per_hourinsecurity.yaml - Cache key:
user_invite_attempts:{user_id} - TTL: 1 hour, starting from the first resend in the window (fixed window, not a rolling/sliding window); the window resets once that hour elapses
<= 0means unlimited
Both checks can be disabled together via security.invitation_rate_limiting.enabled: false.
Configuration Example:
# config/security.yaml
invitation_rate_limiting:
enabled: true
resend_cooldown_minutes: 5 # Min time between resends
max_resends_per_hour: 3 # Max resends in 1-hour windowThese defaults are hard-coded Go fallbacks and stay in effect in production even if the config is missing. In non-production environments the value can also be overridden at runtime via the /v1/admin/app-config/entries API (module security, path invitation_rate_limiting.*) without a redeploy.
Rate 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: 5 minutes between resends for the same user
- Configuration:
security.invitation_rate_limiting.resend_cooldown_minutesinsecurity.yaml(<= 0disables the check) - Cache key:
user_invite_cooldown:{user_id} - Blocks immediate retries after sending invitation
2. Fixed Window (Prevents sustained abuse)
- Default: Maximum 3 resends per hour per user, starting from the first resend
- Configuration:
security.invitation_rate_limiting.max_resends_per_hourinsecurity.yaml(<= 0means unlimited) - Cache key:
user_invite_attempts:{user_id} - Prevents sustained attack over time with multiple resend attempts
Both checks can be disabled together via security.invitation_rate_limiting.enabled: false.
Configuration Example:
# config/security.yaml
invitation_rate_limiting:
enabled: true
resend_cooldown_minutes: 5 # Min time between resends
max_resends_per_hour: 3 # Max resends in 1-hour windowThe Go constants are the production fallback and apply even if the config is missing. Non-production environments can also override these at runtime via /v1/admin/app-config/entries (module security, path invitation_rate_limiting.*) without a redeploy.
Recommended Profiles:
- Strict:
cooldown=15min, max=2/hour- High security environments - Balanced:
cooldown=5min, max=3/hour- Default (production) - Lenient:
cooldown=1min, max=10/hour(orenabled=false) - 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 the current fixed hourly 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