Appearance
Two-Factor Authentication (2FA)
Summary
Users can optionally enable a second authentication factor on their account. Two methods are supported:
- Email OTP — a 6-digit code sent to the user's registered email address.
- TOTP — a time-based one-time password generated by an authenticator app (Google Authenticator, Authy, etc.).
Both methods can be active simultaneously. When at least one method is active, the login flow requires a second verification step before issuing access/refresh tokens.
Clients (agency-scoped accounts) do not have 2FA support.
All 2FA routes in this branch live under /api/identity/two-factor.
Business Rules
- A user can have at most one method entry per type (EMAIL or TOTP), enforced by a
UNIQUE(userId, method)database constraint. - Setup requires a verification step (activate) to confirm the user controls the second factor before it becomes active.
- Deactivation requires a valid code for the method being used as proof of possession.
POST /deactivatecan disable either one method or every currently active method in the same request via the optionaldeactivateAllflag.- Password change is also protected by 2FA when the user has at least one active method.
- Email OTP codes are hashed before storage and expire after 15 minutes.
- A challenge is locked after 5 failed attempts.
- Sending a new email challenge invalidates previous active challenges for the same user.
- TOTP secrets are encrypted at rest with AES-256-GCM via
IEncryptionService. - TOTP verification allows a ±30-second window (
window: 1) to account for clock drift.
Supported Flows
Flow Map
flowchart TD
A[User has no 2FA] --> B[Authenticated setup flow]
B --> C{Method}
C -->|EMAIL| D[POST /api/identity/two-factor/email/setup]
C -->|TOTP| E[POST /api/identity/two-factor/totp/setup]
D --> F[POST /api/identity/two-factor/email/activate]
E --> G[POST /api/identity/two-factor/totp/activate]
F --> H[Method active]
G --> H
H --> I[User logs in]
I --> J[API returns twoFactorToken + availableMethods]
J --> K{Chosen method}
K -->|EMAIL| L[POST /api/identity/two-factor/email/send-code]
K -->|TOTP| M[Enter authenticator code]
L --> N[POST /api/identity/two-factor/verify]
M --> N
N --> O[Access token + refresh token + profile + session + organizations list]
H --> P[Protected authenticated action]
P --> Q[POST /api/identity/two-factor/challenge/send-email-code or provide TOTP code]
Q --> R[Send x-two-factor-method + x-two-factor-code headers]
R --> S[Guard validates challenge before continuing]
Setup and Activation
Email and TOTP follow a two-step activation model so the method is not marked active until the user proves possession.
sequenceDiagram
participant U as User
participant API as Identity API
participant DB as Identity DB
participant N as Notifications
U->>API: POST /api/identity/two-factor/email/setup
API->>DB: Upsert EMAIL method as inactive
API->>DB: Invalidate active email challenges
API->>DB: Store hashed SETUP code
API->>N: Publish two-factor email event
API-->>U: 200 Verification code sent
U->>API: POST /api/identity/two-factor/email/activate { code }
API->>DB: Load latest active SETUP challenge
alt Invalid / expired / locked
API-->>U: 401/404 error
else Valid code
API->>DB: Mark challenge used
API->>DB: Activate EMAIL method
API-->>U: 200 Email two-factor authentication activated
end
For TOTP, setup returns a shared secret plus a QR code data URI. Activation verifies the live authenticator code before the method becomes active.
Login Flow with 2FA
When a user with active 2FA methods authenticates through password login or Google OAuth, the API returns a two-factor response instead of access/refresh tokens:
POST /api/identity/auth/user/login -> { twoFactorToken, availableMethods: ["EMAIL", "TOTP"] }- The
twoFactorTokenis a short-lived JWT (5 minutes) withpurpose: 'two-factor'. - The client presents a method choice and collects the code from the user.
- For email method, the client calls
POST /api/identity/two-factor/email/send-codewith{ purpose: "LOGIN" }to trigger the OTP email. - The client submits
POST /api/identity/two-factor/verifywith{ method, code }using thetwoFactorTokenas a Bearer token. - On success, the API returns the standard login response (access token, refresh token, user profile, session).
The jwt-two-factor Passport strategy validates the temporary token, ensuring purpose === 'two-factor'.
POST /api/identity/auth/user/verify-email does not branch into 2FA in the current implementation. After email verification, the API issues the normal login payload directly.
sequenceDiagram
participant C as Client
participant API as Identity API
participant DB as Identity DB
C->>API: POST /api/identity/auth/user/login
API->>DB: Validate credentials and inspect active 2FA methods
API-->>C: { twoFactorToken, availableMethods }
alt EMAIL selected
C->>API: POST /api/identity/two-factor/email/send-code { purpose: LOGIN }
API->>DB: Invalidate active email challenges
API->>DB: Store hashed LOGIN code
API-->>C: 200 Verification code sent
else TOTP selected
C->>C: Read code from authenticator app
end
C->>API: POST /api/identity/two-factor/verify { method, code }
API->>DB: Validate challenge or TOTP code
API->>DB: Create or refresh session
API-->>C: Access token + Refresh token + Profile + Session
Authenticated Challenge-Protected Actions
Not all 2FA usage happens at login. Some authenticated actions are guarded by TwoFactorChallengeGuard through @RequiresTwoFactor(...) metadata.
In this branch, password change uses TwoFactorChallengePurpose.CHANGE_PASSWORD.
- The user starts the sensitive action.
- If the account has no active 2FA methods, the request continues normally.
- If the account has active methods, the guard requires both headers:
x-two-factor-methodx-two-factor-code
- For email-based proof, the client first calls
POST /api/identity/two-factor/challenge/send-email-codewith{ purpose: "CHANGE_PASSWORD" }or{ purpose: "DEACTIVATION" }. - The guarded endpoint validates the supplied method and code before executing the action.
If headers are missing, the API returns CORE.TWO_FACTOR_REQUIRED with availableMethods so the client can prompt the user.
API Endpoints
All endpoints are under /api/identity/two-factor.
Setup & Activation (Authenticated)
| Method | Path | Body | Description |
|---|---|---|---|
GET | /methods | — | List active 2FA methods for the current user |
POST | /email/setup | — | Send a setup OTP code to the user's email |
POST | /email/activate | { code } | Verify setup code and activate email 2FA |
POST | /totp/setup | — | Generate TOTP secret + QR code data URI |
POST | /totp/activate | { code } | Verify TOTP code and activate TOTP 2FA |
POST | /deactivate | { method, code, deactivateAll? } | Deactivate the specified method, or all active methods when deactivateAll is true |
POST | /challenge/send-email-code | { purpose } | Send email challenge for authenticated protected actions |
Login Verification (Two-Factor Token)
| Method | Path | Body | Auth | Description |
|---|---|---|---|---|
POST | /email/send-code | { purpose } | jwt-two-factor token | Send an email OTP for login/deactivation |
POST | /verify | { method, code } | jwt-two-factor token | Exchange 2FA code for access/refresh tokens |
Rate Limits
| Endpoint | Window | Max Requests | Identifier |
|---|---|---|---|
/email/setup | 60s | 3 | userId |
/email/activate | 60s | 5 | userId |
/totp/setup | 60s | 3 | userId |
/totp/activate | 60s | 5 | userId |
/deactivate | 60s | 5 | userId |
/challenge/send-email-code | 60s | 3 | userId |
/verify | 60s | 5 | ip |
/email/send-code | 60s | 3 | ip |
Challenge Purposes
| Purpose | Used By | Notes |
|---|---|---|
SETUP | Email setup activation | Sent by /email/setup |
LOGIN | Login verification | Sent by /email/send-code |
DEACTIVATION | 2FA method deactivation | Used when deactivating EMAIL |
CHANGE_PASSWORD | Password change | Used by challenge-protected password change |
Data Model
identity.two_factor_methods
| Column | Type | Description |
|---|---|---|
id | uuid PK | Method ID (UUID v7) |
user_id | uuid | Owner user ID |
method | varchar | 'EMAIL' or 'TOTP' |
secret | text (nullable) | Encrypted TOTP secret (null for email) |
is_active | boolean | Whether the method is currently active |
last_used_at | timestamptz (nullable) | Last successful verification |
deactivated_at | timestamptz (nullable) | When the method was deactivated |
created_at | timestamptz | Creation timestamp |
updated_at | timestamptz | Last update timestamp |
Unique constraint: (user_id, method).
identity.two_factor_challenges
| Column | Type | Description |
|---|---|---|
id | uuid PK | Challenge ID (UUID v7) |
user_id | uuid | User ID |
method | varchar | 'EMAIL' or 'TOTP' |
code | varchar | Hashed 6-digit code |
purpose | varchar | 'SETUP', 'LOGIN', or 'DEACTIVATION' |
expires_at | timestamptz | Code expiration (15 minutes from creation) |
used_at | timestamptz (nullable) | When the code was successfully used |
attempts | integer | Failed verification attempts (max 5) |
created_at | timestamptz | Creation timestamp |
updated_at | timestamptz | Last update timestamp |
Failure Modes
| Error Code | HTTP | Trigger |
|---|---|---|
IDENTITY.TWO_FACTOR_ALREADY_ACTIVE | 409 | Setup when method is already active |
IDENTITY.TWO_FACTOR_NOT_FOUND | 404 | Deactivate a method that doesn't exist |
IDENTITY.TWO_FACTOR_SETUP_NOT_FOUND | 404 | Activate without a prior setup |
IDENTITY.INVALID_TWO_FACTOR_CODE | 401 | Code doesn't match |
IDENTITY.TWO_FACTOR_CODE_EXPIRED | 401 | Challenge past expiresAt |
IDENTITY.TWO_FACTOR_CODE_LOCKED | 401 | 5+ failed attempts on a challenge |
IDENTITY.INVALID_TWO_FACTOR_TOKEN | 401 | Expired or invalid two-factor JWT |
CORE.TWO_FACTOR_REQUIRED | 403 | Guarded authenticated route needs second-factor headers |
CORE.INVALID_TWO_FACTOR_METHOD | 400 | Header method is not active for the user |
Notifications Integration
Email OTP delivery uses the cross-module integration event pattern:
- Command publishes
TwoFactorEmailCodeIntegrationEventviaIdentityPublisher. TwoFactorEmailCodeConsumerin the Notifications module handles the event.- Uses
SendEmailNotificationCommandwith theTwoFactorCodeemail template. - Template renders purpose-specific messaging (setup confirmation, login verification, deactivation confirmation).
CHANGE_PASSWORD is already a supported challenge purpose in the backend, but the current email template falls back to generic wording for that purpose.
Observability
- Repository operations are wrapped with
postgresMetricsService.recordRepositoryOperation()for latency/error tracking. - Rate limit violations return 429 with
Retry-Afterheader. - All endpoints flow through the global
WideLogInterceptorfor structured request logging.
Frontend Status
The backend 2FA flow is implemented and the panel app now consumes it end-to-end.
- The login flow supports
{ twoFactorToken, availableMethods }and presents a dedicated 2FA step that can use either email or TOTP methods. - The profile security section exposes a "Doble Factor (2FA)" card where users can enable/disable email OTP and TOTP, including QR-based setup for authenticator apps.
- Password change uses a confirmation dialog first and, when the API responds with
CORE.TWO_FACTOR_REQUIRED, the panel prompts for a second-factor code and resends the request withx-two-factor-methodandx-two-factor-codeheaders.
Change Log
- 2026-03-12: Documented the optional
deactivateAllflag for/deactivate, which lets one verified request disable every active 2FA method. - 2026-03-11: Expanded the 2FA doc with authenticated challenge flows, corrected routes/table names, and documented current frontend gaps.
- 2026-03-10: Initial 2FA feature implementation with email OTP and TOTP methods.