Skip to main content

Authentication — Technical Reference

← Back to Authentication

Where this lives

Backend

Frontend (web)

iOS

Technical implementation checklist

  • register / login — resolvers wired in user-authentication.resolver.js; web Login.tsx calls 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 from loginWithFirebase to keep the provider name out of the public mutation name) — resolver wired; called from web (Login.tsx). iOS (FirebaseAuthManager.swift) still calls the old loginWithFirebase field name, which no longer exists on the schema - this currently breaks Google/phone login on iOS until the iOS client (and packages/graphql operations) 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 for requestPhoneOtp/loginWithPhone anywhere in apps/frontend-nextjs)
  • setupTwoFactor / verifyTwoFactorSetup / disableTwoFactor / twoFactorStatus (TOTP/authenticator-app flow) — resolvers wired in user-two-factor-auth.resolver.js; web SecuritySettingsPage.tsx calls 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 in user-two-factor-auth.resolver.js, calling twoFactorAuthManager.enableTwoFactor. verifyTwoFactorCode, generateBackupCodes, and verifyBackupCode remain removed. SecuritySettingsPage.tsx now self-enrolls SMS/email 2FA through it (method picker next to the authenticator-app option), same as authenticator-app 2FA, confirmed via verifyTwoFactorSetup.
  • verifyLoginTwoFactor(twoFactorToken, code) — resolver wired in user-authentication.resolver.js; completes a login that came back with requiresTwoFactor: true. Web Login.tsx calls it, accepting either a TOTP code or a backup code
  • setupTwoFactor/verifyTwoFactorSetup returning backupCodesSecuritySettingsPage.tsx displays and lets the user copy them (handleCopyBackupCodes) right after setup
  • regenerateBackupCodes — resolver wired in user-two-factor-auth.resolver.js; web SecuritySettingsPage.tsx now 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 in webauthn.resolver.js; web SecuritySettingsPage.tsx lists the user's registered passkeys and lets them remove one
  • requestPasswordReset / verifyResetToken / resetPassword — resolvers wired; web ForgotPassword.tsx (/forgot-password) and ResetPassword.tsx (/reset-password) call them; iOS also has this flow (this doc previously said web had no reset screen — corrected)
  • changePassword — resolver wired; web SecuritySettingsPage.tsx calls 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 in apps/frontend-nextjs
  • registerDeviceToken / getUserDeviceTokens / deleteDeviceToken — resolvers wired; iOS only (DeviceTokenService.swift, PushNotificationManager.swift) — confirmed no push token registration anywhere in apps/frontend-nextjs

Sign-in methods

MethodMutation
Email + passwordregister, login
Apple Sign-InloginWithApple
Google / FirebaseloginWithIdToken (web); iOS still on the old loginWithFirebase name - broken until updated
Phone + OTPrequestPhoneOtploginWithPhone
Passkey (WebAuthn)generatePasskeyAuthenticationOptionsverifyPasskeyAuthentication

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 RequestReset { requestPasswordReset(email: "[email protected]") { success } }
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 (generatePasskeyRegistrationOptionsverifyPasskeyRegistration) 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 hasPasskeystrue 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

ComponentDescription
components/Login.tsxCombined login + sign-up form, Google, Apple
components/LoginTailwind.tsxTailwind-styled variant
components/ForgotPassword.tsxRequest a password reset email (/forgot-password)
components/ResetPassword.tsxSet a new password from the emailed link (/reset-password)
page-components/settings/SecuritySettingsPage.tsxChange password, TOTP 2FA setup/verify/disable/regenerate backup codes, passkey list/register/delete (/settings/security)
components/ProtectedRoute.tsxRedirects to /login when unauthenticated
components/PublicRoute.tsxRedirects to /home when already logged in