CorebanqCorebanq Developer Docs
Users

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 address
  • phone: Phone number
  • telegram: Telegram ID
  • totp: Time-based One-Time Password
  • whatsapp: WhatsApp number

MFA Modes

  • off: MFA disabled
  • email: Email-based MFA
  • phone: SMS-based MFA
  • totp: 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-2FA can operate without the explicit X-MFA-Challenge header
  • 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
  • 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:

  1. 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_token in response
  • Does not issue authenticated access or refresh-token session artifacts yet
  1. 2FA Verification (Step 2): User submits OTP
    • Frontend includes challenge token in X-MFA-Challenge header
    • Backend validates:
      • JWT signature and expiration
      • Token subject is "mfa_challenge"
      • Token's user_id matches request user_id
      • Token exists in Redis (not already used)
      • IP/Device changes (logged for monitoring)
    • After successful OTP validation, token is deleted (single-use)
  2. 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_token cannot 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 initiated
  • verified: 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).

Endpointpassword_status present?
GET /v1/users/{id} (self)Yes
GET /v1/users/{id} (other user)No — omitted
GET /v1/usersOnly 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_recommended is true when 0 , 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 by resend-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 input
  • 429 Too Many Requests: IP rate limit exceeded (users_m.password_reset_rate_limit_exceeded). 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 and retry_after is readable only from the rendered message

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 violation
  • 409 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-reset and reset-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:

  1. Requires valid JWT token with "Internal" role
  2. 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 user
  • first_name (optional): User's first name
  • last_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 users
  • 409 Conflict: User with this email already exists
  • 500 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 link
  • password (required): Initial password (min 12 characters, must meet password policy)
  • reference (optional): External reference string persisted on activation
  • terms_accepted (required): Must be true to activate account
  • privacy_policy_accepted (required): Must be true to 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 false terms_accepted / privacy_policy_accepted
  • 409 Conflict: Token already used, user already active
  • 422 Unprocessable Entity: Inconsistent server state after validation (for example missing email credential row); error code users_m.email_credential_not_found. See mfa_console_policy.md in 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:

  1. Requires valid JWT token with "Internal" role
  2. 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 user
  • first_name (required): User's first name
  • last_name (required): User's last name
  • role_ids (optional): Array of role UUIDs to grant
  • department (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 users
  • 409 Conflict: User with this email already exists
  • 500 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 link
  • password (required): Initial password (min 12 characters, must meet password policy)
  • reference (optional): External reference string persisted on activation
  • terms_accepted (required): Must be true to activate account
  • privacy_policy_accepted (required): Must be true to 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 false terms_accepted / privacy_policy_accepted
  • 409 Conflict: Token already used OR user account already active
  • 422 Unprocessable Entity: Same as /v1/users/activate (users_m.email_credential_not_found when 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:

  1. Admin with Internal role + Create permission invites user via /v1/users/admin/invite
  2. System creates inactive user, grants roles, sends invitation email
  3. Invited user receives email with activation link containing token
  4. User opens verification link, client calls /v1/users/verify-invitation, then sets password via /v1/users/activate-internal
  5. 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 found
  • 403 Forbidden: User lacks Internal role (only internal users can resend invitations)
  • 404 Not Found: User does not exist
  • 429 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 found
  • 403 Forbidden: User lacks Internal role (only internal users can resend invitations)
  • 404 Not Found: User does not exist
  • 429 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:

  1. User never received invitation OR token expired
  2. Admin calls resend endpoint for that user
  3. System validates user is inactive, generates new token
  4. System checks rate limits (cooldown + fixed hourly window)
  5. New invitation email sent with fresh activation link
  6. 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:

StatusCodeCause
401common.unauthorizedNo bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup
401permission deniedauth.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
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missingThe 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
429rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limitauth.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
429Global rate limit exceededThe 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
500common.server_errorThe 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
503auth_m.internal_server_errorThe 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

CodeDescription
users_m.invalid_user_inputInvalid user input data
users_m.name_already_existsUsername already taken
users_m.invalid_emailInvalid email format
users_m.invalid_phoneInvalid phone number
users_m.invalid_mfa_modeInvalid MFA mode
users_m.user_already_existsUser with this email already exists
users_m.user_already_activeUser account is already active

Permission Errors

CodeDescription
users_m.insufficient_permissionsUser lacks required permissions
users_m.user_creation_permission_deniedUser lacks Create permission on users

Token Errors

CodeDescription
users_m.invalid_tokenInvalid or expired invitation token
users_m.token_already_usedInvitation token has already been used
users_m.token_generation_failedFailed to generate invitation token

Credential Errors

CodeDescription
users_m.credential_already_validatedCredential already validated
users_m.duplicate_credentialCredential already exists
users_m.invalid_credential_idInvalid credential ID
users_m.credential_not_foundCredential not found
users_m.email_credential_not_foundNo email credential found for user

Rate Limiting Errors

CodeDescription
users_m.invitation_resend_cooldown_activeCooldown period active, must wait before resending
users_m.invitation_resend_limit_exceededMaximum resend attempts exceeded within the current fixed hourly window
otp_m.resend_too_soonA code was issued for this credential less than otp.resend.min_interval ago
otp_m.resend_limit_exceededThe credential has spent otp.resend.max_per_window codes inside otp.resend.window
otp_m.cooling_period_activeThe credential is locked out after too many wrong codes
users_m.activation_rate_limit_exceededPer-IP activation limiter in the handler, on initiate-registration, activate, activate-internal and verify-invitation
users_m.password_reset_rate_limit_exceededPer-IP limiter in the handler, on request-password-reset and reset-password

Authentication & MFA Errors

CodeDescription
auth_m.missing_challenge_tokenAuthentication challenge token required for 2FA operations (when 2fa_challenge: true)
auth_m.invalid_challengeInvalid or expired MFA challenge token
auth_m.challenge_already_usedMFA challenge token has already been used (single-use enforcement)
auth_m.invalid_or_expired_otpInvalid 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 in email_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) or internal_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:

  1. Token stored in Redis cache with prefix + token as key
  2. On activation, token is validated from cache
  3. After successful activation, token is deleted from cache
  4. Subsequent use of same token returns token_already_used error

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:

SituationStatusstatus field
Wrong code, attempts left400invalid
Code expired, or already consumed by a concurrent submission400expired
Credential is already validated400already_validated
Correct code, but the address is validated on another credential409duplicate
Wrong code, budget exhausted429cooling (with retry_after in seconds, and wait beside it)
Anything else500error

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:

SituationStatuscode
Resend during an active cooling period429otp_m.cooling_period_active
Resend before the minimum interval elapsed429otp_m.resend_too_soon
Resend after the credential spent its window429otp_m.resend_limit_exceeded
Resend for an already-verified credential400users_m.credential_already_validated
No user matches the credential400auth.invalid_credentials
Stored credential cannot be delivered to400users_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:

  1. 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
  2. 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 length

Refusal (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:

  1. Cooldown Period (Prevents rapid successive resends)

    • Default: 5 minutes between resends for the same user
    • Configurable: security.invitation_rate_limiting.resend_cooldown_minutes in security.yaml
    • Cache key: user_invite_cooldown:{user_id}
    • TTL: Exactly the configured cooldown duration (no extra buffer)
    • <= 0 disables the cooldown check
  2. Fixed Window (Prevents sustained abuse)

    • Default: Maximum 3 resends per hour per user
    • Configurable: security.invitation_rate_limiting.max_resends_per_hour in security.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
    • <= 0 means 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 window

These 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_id for traceability

Database Schema

Users Table:

  • active: Set to false during invitation, true after activation
  • email_verified: Set to true after successful activation
  • hashed_password: Set during activation (not during invitation)
  • last_password_change_at: Set to activation timestamp
  • password_expires_at: Set to activation timestamp + expiration duration

Persona Linking:

  • For internal users: First name + Last name stored in personas table
  • Linked via persona_links table with rec_type_name = 'users'
  • For regular users: Persona is optional, created only if names provided

Role Assignment:

  • Internal users: Insert into user_roles table with role_id = Internal role UUID
  • Additional roles (if provided): Inserted as separate user_roles records
  • 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 address

Error 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:

  1. Invite User - Test regular user invitation
  2. Invite Privileged User - Test privileged user invitation with roles
  3. Activate User Account - Test activation with password
  4. 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

CodeDescription
users_m.password_changedPassword successfully changed
users_m.otp_error_validatingError validating OTP
users_m.totp_invalid_codeInvalid TOTP code

Authentication & MFA Security Errors

CodeDescriptionWhen Occurs
auth_m.missing_challenge_tokenMFA challenge token required2FA operation without X-MFA-Challenge header when 2fa_challenge: true
auth_m.invalid_challengeInvalid or expired challenge tokenJWT signature invalid, expired, or wrong subject type
auth_m.challenge_already_usedChallenge token already usedToken not in Redis cache (already used or expired)
auth_m.invalid_or_expired_otpInvalid or expired OTPOTP 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 token

2. 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 exploited

Scenario 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 applies

Scenario 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 use

Implementation 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:

  1. Extract X-MFA-Challenge header from request
  2. Validate JWT signature (HMAC-SHA256)
  3. Verify sub claim equals "mfa_challenge"
  4. Verify exp not expired (5 minutes)
  5. Verify user_id in token matches request
  6. Check token exists in Redis (key: mfa_challenge:{token})
  7. Log IP/Device changes (monitoring, not blocking)
  8. After OTP success: Delete token from Redis

Endpoints Affected:

  • POST /v1/verify-2FA - Requires X-MFA-Challenge header (when enabled)
  • POST /v1/auth/totp/setup - Requires challenge token in Authorization header (when enabled)
  • POST /v1/auth/totp/verify - Requires challenge token in Authorization header (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-Challenge header
  • ✅ Redis cache available

Step 2: Enable in Configuration

# auth app-config module
2fa_challenge: true

Step 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:

  1. Internal user activates account and attempts login
  2. API detects mfa_mode = "totp" but totp_enabled = false
  3. API returns challenge token with setup-required status
  4. User generates TOTP secret and scans QR code via /v1/totp/setup
  5. User verifies TOTP code via /v1/totp/verify to complete setup
  6. TOTP is enabled (totp_enabled = true)
  7. 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_minutes in security.yaml (<= 0 disables 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_hour in security.yaml (<= 0 means 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 window

The 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 (or enabled=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_id for 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.md for all security audit findings

On this page

Purpose and useOverviewCore ConceptsUser CredentialsMFA ModesMFA Session BindingBackward Compatibility Mode (2fa_challenge: false)Enhanced Security Mode (2fa_challenge: true) - RECOMMENDEDRegistration StatusPassword PolicyPassword Expiry Status (password_status)Known residual: response time still separates the two casesUser RegistrationInitiate RegistrationVerify RegistrationUser ManagementCreate UserCreate User with PersonaGet UserList UsersCredential ManagementAdd CredentialValidate CredentialUpdate CredentialPassword ManagementChange PasswordRequest Password ResetReset PasswordUser RolesGet Users by Role IDGet Users by Role NameAdmin OperationsInvite UserActivate User AccountVerify Invitation TokenInvite Privileged UserActivate Internal UserResend Invitation (Regular User)Resend Invitation (Internal User)Error HandlingRefusals the middleware writes before the handler runsError CodesUser ErrorsPermission ErrorsToken ErrorsCredential ErrorsRate Limiting ErrorsAuthentication & MFA ErrorsImplementation NotesUser Invitation SystemRegular User Invitation (/v1/users/invite)Internal User Invitation (/v1/users/admin/invite)Token SecurityEmail ConfigurationOTP Attempt Budget and Cooling-OffPacing OTP SendsRate Limiting for Invitation ResendsDatabase SchemaConfiguration RequirementsError MessagesTesting with PostmanPassword ErrorsAuthentication & MFA Security ErrorsSecurity FeaturesMFA Session Binding (Authentication Bypass Prevention)OverviewSecurity ModesAttack Scenarios PreventedImplementation DetailsMigration GuideCompliance & AuditTOTP Enforcement for Internal UsersRate Limiting for Invitation ResendsAdditional Security Documentation