Skip to main content

Security & 2FA — Technical Reference

← Back to Security & 2FA

Where this lives

Backend

Frontend apps/frontend-nextjs now has dedicated Settings pages for this, all linked from the settings sidebar (page-components/SettingsPage.tsx):

frontend-admin's /verification page handles the approve/reject/remove side (see the checklist below).

Technical implementation checklist

  • setupTwoFactor / verifyTwoFactorSetup — live in user-two-factor-auth.resolver.js and wired end to end in SecuritySettingsPage.tsx; the old enableTwoFactor / verifyTwoFactorCode schema fields were removed as stale stubs (the manager methods of the same name still exist and back the login-time 2FA step via verifyLoginTwoFactor)
  • disableTwoFactor, regenerateBackupCodes — live and wired in SecuritySettingsPage.tsx; the old verifyBackupCode / generateBackupCodes schema fields were removed as stale stubs — backup-code consumption happens inside verifyLoginTwoFactor at login time, and regeneration is regenerateBackupCodes (no args)
  • activeSessions / getCurrentSession / sessionDetails / loginHistory — all wired; activeSessions and loginHistory have frontend in SessionsSettingsPage.tsx
  • revokeSession / revokeAllOtherSessions — live and wired in SessionsSettingsPage.tsx; terminateSession, terminateAllSessions, and terminateOtherSessions are also wired (aliases of revokeSession / revokeAllOtherSessions)
  • JWT verification now enforces live-session state — fixed this session. graphql/context/auth-helper.js#verifyTokenAndGetUser used to only check the JWT's signature/expiry, never user_session - so revokeSession/terminateAllSessions/logging out didn't actually stop a token from authenticating; it just changed a DB row nothing ever read again. It now also calls userSessionAccessService.isValid(decoded.sessionId) and rejects the request if the session is inactive/revoked/expired. See Sessions & Login History for the new logout mutation this pairs with, and admin-auth-helper.js for the equivalent admin-side check this mirrors.
  • securityAlerts / markSecurityAlertRead / dismissSecurityAlert — live and wired in SecurityAlertsPage.tsx; there's no separate mySecurityAlerts field — securityAlerts is the one in the schema
  • securityEventLog / securityStats — both wired: securityEventLog reads the persisted audit trail (shown in SecurityAlertsPage.tsx), and securityStats returns real counts (logins, failed/suspicious/blocked attempts, device count) derived from security_event
  • checkSuspiciousLogin / detectSuspiciousActivity — wired; no frontend UI
  • Verification badge request/approval/rejection/removal — request submission is wired via verification.resolver.js and VerificationRequestPage.tsx (/settings/verification); admin approve/reject/remove is wired in graphql/resolvers/admin/user-moderation.resolver.js and the frontend-admin /verification page — the badge itself displays on profiles via isVerified

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.