Every sign-in fingerprints the device and checks it against what the account has used before. 2FA sits on top of that.
Device fingerprinting
On each sign-in:
1. Fingerprint and identify
generateDeviceFingerprint(meta) builds a fingerprint from request metadata; generateDeviceId(fingerprint, userId) derives a stable per-user device id.
2. Check the blocklist
A blocked device is 403 — This device has been blocked. Please contact support. before credentials matter.
3. Compare with known devices
isKnownDevice(orgId, userId, 'user', deviceId).
4. Register or update
Unknown devices are registered with IP and geo location, and — unless alertOnNewDeviceLogin is false — trigger an alert email. Known devices just get their activity timestamp bumped.
5. Record the login
recordLogin(orgId, userId, 'user', deviceId, ip, location, success, method) for the audit trail, alongside an activity record.
Location comes from the request metadata — realIp, host, country, city, subdivisions, userAgentString.
Managing devices
/profile/security/devicesJWT/profile/security/devices/currentJWT/profile/security/devices/:deviceId/trustJWT/profile/security/devices/:deviceId/blockJWT/profile/security/devices/:deviceIdJWTTrusting a device suppresses repeated 2FA challenges from it. Blocking refuses it outright.
Login history
/profile/security/login-historyJWTEvery attempt — successful or not — with device, IP, location and method.
Two-factor authentication
When it is required
2FA triggers if either the org sets securitySettings.enableTwoFactorForUsers or the user enabled it. isTwoFactorRequired(orgId, userId, 'user', deviceId) then decides per device, so a trusted device is not challenged every time.
Separately, securitySettings.enableNewDeviceAuthentication forces an email challenge on an unrecognized device even when 2FA is otherwise off.
The challenge
Sign-in returns instead of a token:
{ "requiresTwoFactor": true, "challengeToken": "…", "twoFactorMethod": "email", "message": "Verification code sent to your email" }For email and sms the code has already been sent — to data.email or data.phone. For authenticator nothing is sent; the user reads it from their app. When the user has no preference set, the method defaults to email.
/profile/security/challenge/sendNo auth/profile/security/challenge/verifyNo authBoth are public — the caller is holding a challengeToken, not a session. Verifying exchanges it for a real token pair.
Setup
/profile/security/2fa/statusJWT/profile/security/2fa/setup/:methodJWT/profile/security/2fa/verify-setupJWT/profile/security/2fa/enableJWT/profile/security/2fa/disableJWT:method is email, sms or authenticator. For authenticator, setup returns the secret and QR payload (otplib); verify-setup proves the user has it working before enable commits.
Backup codes
/profile/security/2fa/backup-codesJWT/profile/security/2fa/backup-codes/countJWTStored as two_factor_backup records. The count endpoint exists so the UI can nag before the user runs out.
Security settings
/profile/security/settingsJWT/profile/security/settingsJWTOrg-level switches, also reachable through GET /repository/setting/securitySettings:
| Setting | Effect |
|---|---|
enableTwoFactorForUsers | Force 2FA for every user in the org |
enableNewDeviceAuthentication | Challenge unrecognized devices even without 2FA |
alertOnNewDeviceLogin | Email on first sign-in from a new device. On unless explicitly false. |
Passcode behavior lives separately under passcodeLoginSettings.
POST /profile/signin/passcode bypasses the 2FA and new-device challenges by design — a shared till would otherwise be unusable. Control that surface through the card registry and passcode settings instead.