Security & 2FA — Technical Reference
Where this lives
Backend
apps/backend/graphql/resolvers/user.resolver.jswas split up — the security-related resolvers now live in dedicated files:user-two-factor-auth.resolver.js(twoFactorStatus,setupTwoFactor,verifyTwoFactorSetup,disableTwoFactor,regenerateBackupCodes),user-security-alerts.resolver.js(securityAlerts,markSecurityAlertRead,dismissSecurityAlert,securityNotificationSettings,checkSuspiciousLogin,getSecurityAlertsStats,securityStats,securityEventLog),user-sessions.resolver.js(activeSessions,sessionDetails,loginHistory,getCurrentSession,detectSuspiciousActivity,revokeSession/terminateSession,revokeAllOtherSessions/terminateAllSessions/terminateOtherSessions), anduser-authentication.resolver.js(verifyLoginTwoFactor, the mutation that completes a login gated on 2FA)apps/backend/graphql/types/two-factor-auth.type.jsandsecurity.type.js— schema definitions. The old stub fieldsenableTwoFactor,verifyTwoFactorCode,verifyBackupCode, andgenerateBackupCodeswere removed from the schema (see the note inauth-verification.type.js) because they didn't match what their resolvers actually accepted/returned — the live 2FA mutations aresetupTwoFactor/verifyTwoFactorSetup/disableTwoFactor/regenerateBackupCodes. The underlying manager still exposesenableTwoFactor/verifyTwoFactorCode/verifyBackupCodemethods, used internally by the login-time 2FA flow.apps/backend/managers/user-managers/two-factor-auth.manager.js— TOTP/SMS/email 2FA setup, verification, and backup-code logicapps/backend/managers/user-managers/security-alerts.manager.js— security alert generation and dismissalapps/backend/managers/user-managers/sessions-devices.manager.js— session and suspicious-login logicapps/backend/data-access-services/user/user-two-factor.access-service.js— 2FA database access
Frontend
apps/frontend-nextjs now has dedicated Settings pages for this, all linked from the settings sidebar (page-components/SettingsPage.tsx):
page-components/settings/SecuritySettingsPage.tsx(/settings/security) — change password, the full 2FA setup/verify/disable/regenerate-backup-codes flow, and passkey managementpage-components/settings/SecurityAlertsPage.tsx(/settings/security-alerts) — alert list (mark-as-read/dismiss), the persisted security event log, and a client-computed security scorepage-components/settings/SessionsSettingsPage.tsx(/settings/sessions) — active sessions, revoke one/all-others, login historypage-components/settings/VerificationRequestPage.tsx(/settings/verification) — submit a verification badge request
frontend-admin's /verification page handles the approve/reject/remove side (see the checklist below).
Technical implementation checklist
-
setupTwoFactor/verifyTwoFactorSetup— live inuser-two-factor-auth.resolver.jsand wired end to end inSecuritySettingsPage.tsx; the oldenableTwoFactor/verifyTwoFactorCodeschema fields were removed as stale stubs (the manager methods of the same name still exist and back the login-time 2FA step viaverifyLoginTwoFactor) -
disableTwoFactor,regenerateBackupCodes— live and wired inSecuritySettingsPage.tsx; the oldverifyBackupCode/generateBackupCodesschema fields were removed as stale stubs — backup-code consumption happens insideverifyLoginTwoFactorat login time, and regeneration isregenerateBackupCodes(no args) -
activeSessions/getCurrentSession/sessionDetails/loginHistory— all wired;activeSessionsandloginHistoryhave frontend inSessionsSettingsPage.tsx -
revokeSession/revokeAllOtherSessions— live and wired inSessionsSettingsPage.tsx;terminateSession,terminateAllSessions, andterminateOtherSessionsare also wired (aliases ofrevokeSession/revokeAllOtherSessions) - JWT verification now enforces live-session state — fixed this session.
graphql/context/auth-helper.js#verifyTokenAndGetUserused to only check the JWT's signature/expiry, neveruser_session- sorevokeSession/terminateAllSessions/logging out didn't actually stop a token from authenticating; it just changed a DB row nothing ever read again. It now also callsuserSessionAccessService.isValid(decoded.sessionId)and rejects the request if the session is inactive/revoked/expired. See Sessions & Login History for the newlogoutmutation this pairs with, andadmin-auth-helper.jsfor the equivalent admin-side check this mirrors. -
securityAlerts/markSecurityAlertRead/dismissSecurityAlert— live and wired inSecurityAlertsPage.tsx; there's no separatemySecurityAlertsfield —securityAlertsis the one in the schema -
securityEventLog/securityStats— both wired:securityEventLogreads the persisted audit trail (shown inSecurityAlertsPage.tsx), andsecurityStatsreturns real counts (logins, failed/suspicious/blocked attempts, device count) derived fromsecurity_event -
checkSuspiciousLogin/detectSuspiciousActivity— wired; no frontend UI - Verification badge request/approval/rejection/removal — request submission is wired via
verification.resolver.jsandVerificationRequestPage.tsx(/settings/verification); admin approve/reject/remove is wired ingraphql/resolvers/admin/user-moderation.resolver.jsand thefrontend-admin/verificationpage — the badge itself displays on profiles viaisVerified
Two-Factor Authentication (2FA / TOTP)
twoFactorStatus returns whether 2FA is currently enabled, how many backup codes remain, and when it was last used — use this to render the 2FA settings card.
The activation flow is two steps: setupTwoFactor (no args) generates a TOTP secret plus a QR code — as a qrCode data-URL PNG, a CSP-safe inline qrCodeSvg, and the raw otpauthUrl for clients that render their own QR — then verifyTwoFactorSetup confirms the setup by verifying the first TOTP code (TwoFactorSetupInput { code }). On success it returns a set of one-time backupCodes the user should store securely.
disableTwoFactor turns off 2FA — takes a TwoFactorVerifyInput { code } with the current TOTP code as confirmation. regenerateBackupCodes (no args) regenerates the backup set, invalidating the old ones.
query TwoFactorStatus { twoFactorStatus { isEnabled backupCodesCount lastUsed setupDate } }
# Step 1 — returns secret + QR code (PNG data-URL, inline SVG, and the raw otpauth:// URL)
mutation SetupTwoFactor {
setupTwoFactor { success message qrCode qrCodeSvg otpauthUrl secret backupCodes }
}
# Step 2 — confirm with first TOTP code from authenticator app
mutation VerifyTwoFactorSetup($code: String!) {
verifyTwoFactorSetup(input: { code: $code }) { success message backupCodes }
}
# Turn off 2FA (requires valid TOTP code)
mutation DisableTwoFactor($code: String!) {
disableTwoFactor(input: { code: $code }) { success message }
}
# Regenerate backup codes (invalidates old set)
mutation RegenerateBackupCodes { regenerateBackupCodes { success message backupCodes } }
There is no standalone verifyBackupCode or enableTwoFactor mutation in the schema — those names were removed as stale stubs. Backup-code consumption happens as part of completing a 2FA-gated login (see below), and enabling 2FA is the setupTwoFactor / verifyTwoFactorSetup pair above.
Active sessions
activeSessions returns all current sessions with device metadata and the isCurrent flag. The currentSession shortcut is pre-selected for "This device" display. Use expiresAt to show when a session will auto-expire.
loginHistory shows all sign-in attempts — both successful and failed. Failed entries include failureReason (e.g. invalid_password, account_suspended) — use this to surface a security alert if the user sees unfamiliar failed attempts.
query ActiveSessions {
activeSessions {
sessions {
id deviceName deviceType browser os
ipAddress location isCurrent lastActivity createdAt
}
total
currentSession { id deviceName }
}
}
query GetCurrentSession { getCurrentSession { id deviceName isCurrent } }
query LoginHistory($limit: Int) {
loginHistory(limit: $limit) {
entries {
loginMethod deviceName ipAddress location
success failureReason createdAt
}
total
}
}
# Invalidate a specific session (signs out that device)
mutation TerminateSession($sessionId: ID!) { terminateSession(sessionId: $sessionId) { success } }
# Sign out all devices
mutation TerminateAllSessions { terminateAllSessions { sessionsTerminated } }
# Sign out all devices except the current one
mutation TerminateOtherSessions { terminateOtherSessions { sessionsTerminated } }
Suspicious login detection
checkSuspiciousLogin (no args — it scores the current authenticated user/request) returns isSuspicious, a riskScore, a list of factors (e.g. risk signals detected), and requiresVerification. There's no recommendation field or SuspiciousLoginInput type in the current schema.
query CheckSuspiciousLogin {
checkSuspiciousLogin { isSuspicious riskScore factors requiresVerification }
}
Completing a 2FA-gated login
login returns an AuthResponse. If the account has 2FA enabled, token comes back null and requiresTwoFactor is true, along with a short-lived twoFactorToken and the twoFactorMethod to prompt for (authenticator, sms, or email). The client then calls verifyLoginTwoFactor with that token and the code — which accepts either the TOTP/SMS/email code or a backup code (checked first, and consumed if used) — to get the real session token.
mutation VerifyLoginTwoFactor($twoFactorToken: String!, $code: String!) {
verifyLoginTwoFactor(twoFactorToken: $twoFactorToken, code: $code) {
user { id username }
token
requiresTwoFactor
}
}
Security alerts
securityAlerts(limit, offset, severity) returns a SecurityAlertsResponse with the alert list plus total and unreadCount. Each SecurityAlert has an alertType (e.g. suspicious_login, new_login, password_change, email_change, two_factor_change), a severity (low/medium/high/critical), a title/description, and an isRead flag rather than a dismiss flag — markSecurityAlertRead and dismissSecurityAlert both take an alertId and return a plain Boolean. A password_change alert is emitted automatically by password.manager.js on password change, and a two_factor_disabled alert by two-factor-auth.manager.js when 2FA is turned off.
type SecurityAlert {
id: ID!
userId: ID!
alertType: String!
severity: String! # "low", "medium", "high", "critical"
title: String!
description: String!
isRead: Boolean!
createdAt: DateTime!
metadata: JSON
}
query SecurityAlerts($limit: Int, $offset: Int, $severity: String) {
securityAlerts(limit: $limit, offset: $offset, severity: $severity) {
alerts { id alertType severity title description isRead createdAt }
total
unreadCount
}
}
mutation MarkSecurityAlertRead($alertId: ID!) { markSecurityAlertRead(alertId: $alertId) }
mutation DismissSecurityAlert($alertId: ID!) { dismissSecurityAlert(alertId: $alertId) }
Security notification settings
Each type of security event can be toggled independently. updateSecurityNotificationSettings persists changes. enableSecurityNotifications / disableSecurityNotifications are bulk shortcuts.
query SecurityNotificationSettings {
securityNotificationSettings {
loginAlerts suspiciousActivity passwordChanges
emailChanges twoFactorChanges newDeviceLogin
accountRecovery privacyChanges
}
}
mutation UpdateSecurityNotificationSettings($input: SecurityNotificationSettingsInput!) {
updateSecurityNotificationSettings(input: $input) { loginAlerts newDeviceLogin }
}
mutation EnableSecurityNotifications { enableSecurityNotifications }
mutation DisableSecurityNotifications { disableSecurityNotifications }
Security event log
securityEventLog is a read-only audit trail of all security-relevant actions on the account (logins, password changes, 2FA changes, etc.). Each event has a riskLevel. securityStats summarizes the log — use it for a security score dashboard. detectSuspiciousActivity runs a real-time check and returns whether suspicious patterns were found.
query SecurityEventLog($limit: Int) {
securityEventLog(limit: $limit) {
id eventType description ipAddress userAgent location riskLevel createdAt
}
}
query SecurityStats {
securityStats {
totalLogins failedAttempts suspiciousActivities
blockedAttempts devicesCount lastSecurityScan
}
}
query DetectSuspiciousActivity { detectSuspiciousActivity }
Account verification (badge)
getVerificationStatus returns the user's current verification state and category. verificationBadgeInfo returns the rendering data (color, icon, display name) needed to display the badge without hardcoding per category.
requestVerification submits a new verification application (VerificationRequestPage.tsx, /settings/verification) — verificationPriceCoins tells the client how many coins the request costs up front. grantVerification / removeVerification still exist in the schema, but the admin review flow (frontend-admin /verification) actually goes through the admin-gated equivalents in graphql/resolvers/admin/user-moderation.resolver.js: adminGetPendingVerificationRequests (queue), adminVerifyUser (approve), adminRejectVerificationRequest (reject a pending request), and adminRemoveVerification (revoke an already-granted badge).
query VerificationStatus($userId: ID!) {
getVerificationStatus(userId: $userId) {
isVerified verificationStatus verificationCategory verifiedAt verificationRequestedAt
}
}
query VerificationBadgeInfo($userId: ID!) {
verificationBadgeInfo(userId: $userId) {
isVerified badgeType verificationCategory badgeColor badgeIcon displayName
}
}
mutation RequestVerification($input: RequestVerificationInput!) {
requestVerification(input: $input) { success message }
}
# Admin only:
mutation AdminGetPendingVerificationRequests($limit: Int, $offset: Int) {
adminGetPendingVerificationRequests(limit: $limit, offset: $offset) { requests { id user { id username } verificationCategory } total }
}
mutation AdminVerifyUser($userId: ID!, $verificationType: String) {
adminVerifyUser(userId: $userId, verificationType: $verificationType) { success message }
}
mutation AdminRejectVerificationRequest($userId: ID!, $reason: String) {
adminRejectVerificationRequest(userId: $userId, reason: $reason) { success message }
}
mutation AdminRemoveVerification($userId: ID!, $reason: String!) {
adminRemoveVerification(userId: $userId, reason: $reason) { success message }
}
Presence & activity
getUserPresence returns a user's live online status, last seen time, and current activity (if sharing activity status is enabled). getUserActivityStats returns usage analytics for a timeframe — useful on the profile analytics screen. updateLastSeen and setUserOffline are called by the client heartbeat and on app background/close.
query UserPresence($userId: ID!) {
getUserPresence(userId: $userId) { isOnline lastSeen status activity deviceType }
}
query ActivityStats($userId: ID!, $timeframe: String) {
getUserActivityStats(userId: $userId, timeframe: $timeframe) {
totalTime activeDays mostActiveHour activityStreak lastActivity
}
}
# Called by client heartbeat to update online presence
mutation UpdateLastSeen { updateLastSeen }
# Called when the app moves to background or the user logs out
mutation SetUserOffline { setUserOffline }
Data export (GDPR)
See Data Export for the full API.
mutation RequestDataExport($input: DataExportRequestInput!) {
requestDataExport(input: $input) { success exportId estimatedCompletion }
}
query DataExportStatus($exportId: ID) {
dataExportStatus(exportId: $exportId) {
status progressPercentage downloadUrl expiresAt fileSize
}
}
query MyDataExports { myDataExports { id status exportType requestedAt completedAt downloadUrl } }
mutation CancelDataExport($exportId: ID!) { cancelDataExport(exportId: $exportId) { success } }
mutation DeleteDataExport($exportId: ID!) { deleteDataExport(exportId: $exportId) { success } }
Export options
exportType accepts: full, posts, messages, media, profile.
Optional filters: includePosts, includeComments, includeMessages, includeMedia, includeProfile, dateRangeStart, dateRangeEnd.