Authentication — Technical Reference
Where this lives
Backend
apps/backend/graphql/resolvers/user-authentication.resolver.js— register, login, loginWithApple, loginWithIdToken, phone OTP (this doc previously cited a singleuser.resolver.js— that file no longer exists; auth resolvers are split across severaluser-*.resolver.jsfiles — corrected)apps/backend/graphql/resolvers/user-two-factor-auth.resolver.js—twoFactorStatus,setupTwoFactor/verifyTwoFactorSetup(TOTP/authenticator-app flow),disableTwoFactor,regenerateBackupCodes, andenableTwoFactor(SMS/email method — see checklist below);verifyTwoFactorCode/generateBackupCodes/verifyBackupCoderemain removed from this resolver and the schemaapps/backend/managers/user-managers/authentication.manager.js— sign-in business logic across email/password, Apple, Firebase, and phone OTPapps/backend/managers/user-managers/password.manager.js— password reset/change logicapps/backend/services/apple-auth.service.js— verifies Apple identity tokens and authorization codesapps/backend/services/firebase.service.js— verifies Firebase/Google ID tokensapps/backend/services/sms-auth.service.js— sends and verifies phone OTP codesapps/backend/graphql/context/auth-helper.js— resolves the JWT from the Authorization header into the request context
Frontend (web)
apps/frontend-nextjs/src/app/login/page.tsx— login routeapps/frontend-nextjs/src/components/Login.tsx— combined login and sign-up form (email/password viaRegisterDocument/LoginDocument, Google, Apple) — this doc previously said web had no sign-up form; it does (corrected)apps/frontend-nextjs/src/components/LoginTailwind.tsx— Tailwind-styled login variantapps/frontend-nextjs/src/components/ForgotPassword.tsx(routed at/forgot-password) — callsrequestPasswordResetapps/frontend-nextjs/src/components/ResetPassword.tsx(routed at/reset-password) — callsverifyResetTokenthenresetPasswordapps/frontend-nextjs/src/page-components/settings/SecuritySettingsPage.tsx(routed at/settings/security) —changePassword, the full TOTP 2FA flow:twoFactorStatus,setupTwoFactor,verifyTwoFactorSetup(displays and lets the user copy the returnedbackupCodes),disableTwoFactor,regenerateBackupCodes; plus passkey management:myPasskeys,generatePasskeyRegistrationOptions/verifyPasskeyRegistration,deletePasskeyapps/frontend-nextjs/src/components/ProtectedRoute.tsx— redirects to /login when unauthenticatedapps/frontend-nextjs/src/components/PublicRoute.tsx— redirects to /home when already logged inapps/frontend-nextjs/src/contexts/AuthContext.tsx— React context holding the authenticated user/session state
iOS
apps/ios/app/app/Features/Auth— full auth feature module: register, login, Apple/Google sign-in, phone OTP, password reset/changeapps/ios/app/app/Core/Services/Auth/DeviceTokenService.swift— registers the FCM device token after login
Technical implementation checklist
-
register/login— resolvers wired inuser-authentication.resolver.js; webLogin.tsxcalls both (RegisterDocument/LoginDocument) for a combined login+signup form; iOS also has both (RegisterViewModel) (this doc previously said web had no sign-up form — corrected) -
loginWithIdToken(Google Sign-In / phone auth, renamed fromloginWithFirebaseto keep the provider name out of the public mutation name) — resolver wired; called from web (Login.tsx). iOS (FirebaseAuthManager.swift) still calls the oldloginWithFirebasefield name, which no longer exists on the schema - this currently breaks Google/phone login on iOS until the iOS client (andpackages/graphqloperations) is updated to match -
loginWithApple— resolver wired; called from both web (Login.tsx) and iOS (AppleAuthManager.swift) -
requestPhoneOtp/loginWithPhone— resolvers wired; iOS only (RequestPhoneOtpUseCase,VerifyCodeViewModel) — confirmed no phone login UI on web (zero matches forrequestPhoneOtp/loginWithPhoneanywhere inapps/frontend-nextjs) -
setupTwoFactor/verifyTwoFactorSetup/disableTwoFactor/twoFactorStatus(TOTP/authenticator-app flow) — resolvers wired inuser-two-factor-auth.resolver.js; webSecuritySettingsPage.tsxcalls all four (this doc previously said none of the 2FA fields were called from web or iOS — corrected) -
enableTwoFactor(input: { method, phoneNumber })— re-exposed in the schema (two-factor-auth.type.js) and wired inuser-two-factor-auth.resolver.js, callingtwoFactorAuthManager.enableTwoFactor.verifyTwoFactorCode,generateBackupCodes, andverifyBackupCoderemain removed.SecuritySettingsPage.tsxnow self-enrolls SMS/email 2FA through it (method picker next to the authenticator-app option), same as authenticator-app 2FA, confirmed viaverifyTwoFactorSetup. -
verifyLoginTwoFactor(twoFactorToken, code)— resolver wired inuser-authentication.resolver.js; completes a login that came back withrequiresTwoFactor: true. WebLogin.tsxcalls it, accepting either a TOTP code or a backup code -
setupTwoFactor/verifyTwoFactorSetupreturningbackupCodes—SecuritySettingsPage.tsxdisplays and lets the user copy them (handleCopyBackupCodes) right after setup -
regenerateBackupCodes— resolver wired inuser-two-factor-auth.resolver.js; webSecuritySettingsPage.tsxnow calls it (handleRegenerateBackupCodes) so a user can get a fresh set of backup codes later, not just view the initial set (this doc previously said there was no frontend caller — corrected) -
myPasskeys/deletePasskey— resolvers wired inwebauthn.resolver.js; webSecuritySettingsPage.tsxlists the user's registered passkeys and lets them remove one -
requestPasswordReset/verifyResetToken/resetPassword— resolvers wired; webForgotPassword.tsx(/forgot-password) andResetPassword.tsx(/reset-password) call them; iOS also has this flow (this doc previously said web had no reset screen — corrected) -
changePassword— resolver wired; webSecuritySettingsPage.tsxcalls it for authenticated password changes (this doc previously said web had no change-password screen — corrected) -
sendEmailVerification/resendEmailVerification/verifyEmail/isEmailVerified— resolvers wired; confirmed not called from web anywhere inapps/frontend-nextjs -
registerDeviceToken/getUserDeviceTokens/deleteDeviceToken— resolvers wired; iOS only (DeviceTokenService.swift,PushNotificationManager.swift) — confirmed no push token registration anywhere inapps/frontend-nextjs
Sign-in methods
| Method | Mutation |
|---|---|
| Email + password | register, login |
| Apple Sign-In | loginWithApple |
| Google / Firebase | loginWithIdToken (web); iOS still on the old loginWithFirebase name - broken until updated |
| Phone + OTP | requestPhoneOtp → loginWithPhone |
| Passkey (WebAuthn) | generatePasskeyAuthenticationOptions → verifyPasskeyAuthentication |
Registration
register creates a new user account and returns the user object along with a JWT. The token should be saved by the client and sent as the Authorization: Bearer <token> header on subsequent requests.
Required fields: username, email, password. Optional: bio, dateOfBirth, gender, accountType.
mutation Register($input: UserRegistrationInput!) {
register(input: $input) {
user { id username email isEmailVerified }
token
}
}
Email login
login authenticates an existing user by email or username plus password. On success it returns a fresh JWT. The identifier field accepts either an email address or a username — the backend tries both.
mutation Login($input: UserLoginInput!) {
login(input: $input) {
user { id username }
token
}
}
Apple Sign-In
loginWithApple exchanges the tokens returned by Apple's native SDK (identityToken and authorizationCode) for a Closegram JWT. Apple only returns the user's email and name on the very first sign-in, so pass them when available. On subsequent logins those fields will be null — the backend already has the data on file.
Requires Apple Developer configuration and Firebase OAuth setup. See the APPLE_* variables in environment setup (the old standalone APPLE_SIGNIN_IMPLEMENTATION.md file has been removed from the repo).
mutation LoginWithApple(
$identityToken: String!
$authorizationCode: String!
$email: String
$firstName: String
$lastName: String
) {
loginWithApple(
identityToken: $identityToken
authorizationCode: $authorizationCode
email: $email
firstName: $firstName
lastName: $lastName
) {
user { id username }
token
}
}
Google / Firebase
loginWithIdToken accepts a Firebase ID token obtained from the Google Sign-In SDK (the same mutation also backs the phone OTP flow's final step - see below). The backend validates the token against Firebase, then creates or updates the Closegram account and returns a JWT. Pass displayName and photoURL from the Firebase user object so the profile stays current. The mutation is intentionally named after the mechanism (a verified identity token), not the provider behind it - Firebase is an implementation detail that could change later without breaking callers.
mutation LoginWithIdToken(
$idToken: String!
$email: String
$displayName: String
$photoURL: String
) {
loginWithIdToken(idToken: $idToken, email: $email, displayName: $displayName, photoURL: $photoURL) {
user { id username }
token
}
}
Phone OTP
Two-step flow. Step 1 requests an SMS code sent to the given phone number; sessionId must be passed back in step 2 to link the two requests. expiresIn tells the client how many seconds the code is valid.
# Step 1: request OTP — sends an SMS to the given number
mutation RequestOTP {
requestPhoneOtp(phone: "5512345678", countryCode: "+52") {
success sessionId expiresIn
}
}
# Step 2: verify the 6-digit code and receive a JWT
mutation LoginWithPhone($input: PhoneLoginInput!) {
loginWithPhone(input: $input) {
user { id username }
token
}
}
Password management
requestPasswordReset sends a reset link to the user's email. verifyResetToken checks whether a reset token is still valid before showing the new-password form (saves the user a round-trip). resetPassword exchanges the token for a new password; the token is single-use. changePassword is for authenticated users who know their current password.
mutation ResetPassword { resetPassword(token: "...", newPassword: "...") { success } }
mutation ChangePassword { changePassword(input: { currentPassword: "...", newPassword: "..." }) { success } }
query VerifyResetToken { verifyResetToken(token: "...") { valid message } }
Passkeys (WebAuthn)
Passwordless sign-in built on @simplewebauthn. Registration (generatePasskeyRegistrationOptions → verifyPasskeyRegistration) is done while logged in from Settings → Security; credentials live in user_passkey. Login is a two-step ceremony:
mutation GeneratePasskeyAuthOptions($identifier: String) {
generatePasskeyAuthenticationOptions(identifier: $identifier) {
flowId
options
hasPasskeys
}
}
getAuthenticationOptions(identifier) (in services/passkey.service.js) looks up the user's registered credentials and returns them as allowCredentials, keying the challenge in Redis under a random flowId. It also returns hasPasskeys — true only when the identifier has at least one registered credential.
Why hasPasskeys matters (login-flow fix). The landing "Continue" button calls generatePasskeyAuthenticationOptions first, without showing any UI. Only if hasPasskeys is true does the client call navigator.credentials.get() (startAuthentication) and pop the OS passkey prompt. When it's false, the client skips the ceremony entirely and drops to the password step, carrying the typed identifier over. Without this, an empty allowCredentials makes the browser fall into a discoverable-credential flow that shows a "use a passkey / security key" system dialog even for users who have never registered one — which is confusing. The server still generates a full flowId + challenge in every case, so response timing doesn't leak whether the account exists.
The client then verifies:
mutation VerifyPasskeyAuth($flowId: String!, $response: JSON!) {
verifyPasskeyAuthentication(flowId: $flowId, response: $response) {
token
user { id username }
}
}
verifyAuthentication(flowId, response) reads the challenge from Redis, matches response.id to a stored credentialId, verifies the assertion against WEBAUTHN_RP_ID / EXPECTED_ORIGIN, and issues a session token. See the WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN / WEBAUTHN_RP_NAME env vars in environment setup — these must be set in production or registration fails with The RP ID "localhost" is invalid for this domain.
Registered passkeys can be listed and removed from Settings → Security via the myPasskeys query and deletePasskey(id) mutation.
Key files: services/passkey.service.js, graphql/types/webauthn.type.js, graphql/resolvers/webauthn.resolver.js, database/models/UserPasskey.js; client components/Login.tsx (login ceremony) and page-components/settings/SecuritySettingsPage.tsx (registration, listing, and removal).
Two-factor authentication
setupTwoFactor generates a TOTP secret + QR code for an authenticator app (Google Authenticator, Authy, etc.) — step 1 of enrollment. verifyTwoFactorSetup confirms the 6-digit code and completes enrollment, returning a one-time set of backupCodes. disableTwoFactor turns it back off (requires a valid code). twoFactorStatus reports whether 2FA is enabled, how many backup codes remain, and when it was last used/set up. regenerateBackupCodes issues a fresh set of backup codes later; SecuritySettingsPage.tsx calls it.
Once 2FA is enabled, login / loginWithApple / loginWithIdToken don't return a token directly — they return requiresTwoFactor: true, a short-lived twoFactorToken, and twoFactorMethod (authenticator, sms, or email). The client then calls verifyLoginTwoFactor(twoFactorToken, code) — code can be the 6-digit TOTP code or one of the backup codes — to get the real session token. Web Login.tsx implements this second step.
A separate SMS/email-based method, enableTwoFactor(input: { method, phoneNumber }), lets a user turn on SMS/email 2FA from scratch (along with verifyTwoFactorCode, generateBackupCodes, and verifyBackupCode, which remain removed from the schema). It's wired in user-two-factor-auth.resolver.js, which calls twoFactorAuthManager.enableTwoFactor(userId, method, { phoneNumber }) and returns a TwoFactorSetupResponse (the same shape setupTwoFactor uses) — it only initiates setup for the chosen method (sends/generates a code, or a TOTP secret + QR), the same two-step pattern as the authenticator flow, confirmed by verifyTwoFactorSetup. SecuritySettingsPage.tsx now has a real frontend caller: a method picker (Authenticator / SMS / Email) next to the existing authenticator-app button — choosing SMS prompts for a phone number first, choosing Email calls enableTwoFactor directly, and both land on the same code-entry step as the authenticator flow. The sms/email verification path inside two-factor-auth.manager.js still runs at login time for any account that already has that method stored in user_two_factor, dispatching a fresh code and requiring it via verifyLoginTwoFactor.
query TwoFactorStatus { twoFactorStatus { isEnabled backupCodesCount lastUsed setupDate } }
mutation SetupTwoFactor { setupTwoFactor { success message qrCode secret backupCodes } }
mutation EnableTwoFactor($input: EnableTwoFactorInput!) {
enableTwoFactor(input: $input) { success message method qrCode qrCodeSvg secret phoneNumber email codeLength expiresIn }
}
mutation VerifyTwoFactorSetup($input: TwoFactorSetupInput!) {
verifyTwoFactorSetup(input: $input) { success message backupCodes }
}
mutation DisableTwoFactor($input: TwoFactorVerifyInput!) { disableTwoFactor(input: $input) { success message } }
mutation VerifyLoginTwoFactor($twoFactorToken: String!, $code: String!) {
verifyLoginTwoFactor(twoFactorToken: $twoFactorToken, code: $code) { token user { id username } requiresTwoFactor }
}
mutation RegenerateBackupCodes { regenerateBackupCodes { success message backupCodes } }
Email verification
sendEmailVerification / resendEmailVerification both dispatch a verification email, but resend enforces a cooldown to prevent spam. verifyEmail validates the link token from the email and marks the account as verified. isEmailVerified is a lightweight check for gating flows that require a verified email.
mutation SendVerification { sendEmailVerification { success } }
mutation ResendVerification { resendEmailVerification { success } }
mutation VerifyEmail { verifyEmail(token: "...") { success } }
query IsEmailVerified { isEmailVerified }
Device tokens (push notifications)
On login the client registers its FCM device token so the backend knows where to deliver push notifications. platform can be ios, android, or web. On iOS a separate VoIP token is registered for call notifications (see the Calls doc).
mutation RegisterToken($input: RegisterDeviceTokenInput!) {
registerDeviceToken(input: $input) {
id token platform isActive
}
}
# List all registered tokens for the current user
query DeviceTokens { getUserDeviceTokens { id token platform deviceName isActive } }
# Remove a token when the user logs out of a device
mutation DeleteToken($tokenId: ID!) { deleteDeviceToken(tokenId: $tokenId) }
Frontend components
| Component | Description |
|---|---|
components/Login.tsx | Combined login + sign-up form, Google, Apple |
components/LoginTailwind.tsx | Tailwind-styled variant |
components/ForgotPassword.tsx | Request a password reset email (/forgot-password) |
components/ResetPassword.tsx | Set a new password from the emailed link (/reset-password) |
page-components/settings/SecuritySettingsPage.tsx | Change password, TOTP 2FA setup/verify/disable/regenerate backup codes, passkey list/register/delete (/settings/security) |
components/ProtectedRoute.tsx | Redirects to /login when unauthenticated |
components/PublicRoute.tsx | Redirects to /home when already logged in |