Sign-in methods
People sign in on one page with an email, a phone number or Google. You choose which of the three are offered; each account can use any of them it has set up.
What it is
The sign-in page is /signup. It asks one question, "Email" (or "Phone number or email" when phone sign-in is on), and shows Continue with Google when Google is on. What happens next depends on what was typed and whether it is registered:
| Method | Registered | New |
|---|---|---|
| Password | A code by email, then name and password | |
| Phone number | A 6-digit PIN | A code by SMS, then name and a 6-digit PIN |
| One tap | One tap; name, email and photo come from Google |
Whatever the method, a person is one Firebase Auth account plus one users record (see Users and roles). The record is always created by the server, with the site's default role and the arccms_uid claim already in the first ID token.
- Email uses Firebase's password sign-in. The sign-up code is Arc CMS's own: it proves the address before the account exists. Forgot password emails a code, and the new password is chosen on the same page (see Forgot password).
- Phone number is Arc CMS's own sign-in. The number is kept on the
usersrecord, never on the Firebase Auth account. After the right PIN or code the server returns a custom token and the browser signs in with it. - Google is Firebase's Google provider. Firebase keeps one account per email, so a Google sign-in with the address of an existing account lands on that account.
After signing in, people go back to the page that sent them to sign in (/signup?redirect=/learn), else to their role's home page, which an app sets in src/custom/home.ts (see Set up sign-in).
Someone already signed in who opens /signup never sees the form. Firebase Auth knows from the device that they are signed in long before their users record arrives, so meanwhile the page shows the site's logo, a spinner and "Opening…", then forwards them once AuthState.recordReady() has their record (from the device's copy when there is one, see Add pages and routes). The first frame comes from the signed-in hint this browser keeps (arc:signed-in, see Build your home page), so a signed-out visit shows the form at once with no flash. The form shows when Auth says nobody is signed in, when there is no record for them (a deleted account), or after 20 seconds without one (SIGNED_IN_WAIT_MS).
On a phone the page fits the visible screen (100dvh, 100svh where the browser has no dvh), less the "Built with Arc CMS" line when the site shows it: the logo and the card sit together in the middle and the footer stays at the foot. Only a step taller than the screen, such as the sign-up form, scrolls.
Turn it on or off
Sign-in is core and always on. Email sign-in is always offered. The other two have switches under Settings, User Settings, Sign-in methods:
| Switch | Default | Also needs |
|---|---|---|
| Enable User Signups | On | Nothing. Off: only admins can add people (Users, Add User), and existing accounts still sign in. |
| Phone number | Off | The sms feature (on by default), SMS settings, and one Google Cloud setting (see Google Cloud setup for phone sign-in). The switch checks that setting before it turns on. With sms off in src/custom/features.ts, the switch is hidden, phone sign-in is off whatever it says, and the phone functions are not built. |
| Off | The Google provider turned on in the Firebase console. |
The Default Role for New Users is set on the same page. The switches take effect at once, with no deploy.
Set it up
Firebase console, Authentication, Sign-in method: Email/Password must be on (email sign-in uses it). Turn on Google if you want Google sign-in. See Configure a project.
Deploy the functions, Firestore rules and indexes (see Deploy).
Phone numbers only: set up SMS under Settings, SMS: the provider and the countries people can sign in from. Keep the Test provider while trying it. See SMS. The project also needs the Google Cloud setting below; the deploy offers to make it.
Settings, User Settings: leave sign-ups on, and turn on Phone number and Google as wanted.
Try each method on
/signupin a private window.
Google Cloud setup for phone sign-in
The last step of every phone sign-in creates a custom token, which the functions sign as their own service account (for 2nd gen functions, <project-number>-compute@developer.gserviceaccount.com). A new Firebase project cannot do that until:
- the IAM Service Account Credentials API (
iamcredentials.googleapis.com) is on, and - that service account has the Service Account Token Creator role (
roles/iam.serviceAccountTokenCreator), granted on the account itself or on the project.
Three things make sure nobody finds out the hard way:
- The deploy. A deploy that includes functions, with the
smsfeature on, checks both once per project withgcloudand, in a terminal, offers to set up what is missing. Withoutgcloudit says so once. It never stops the deploy (see Deploy). - The switch. Turning on Phone number under Settings, User Settings first signs a throwaway token (the
checkPhoneSignInfunction; it creates no account). If that fails, the switch stays off and the page shows what is missing, agcloudcommand for this project to copy, a link to the Google Cloud console, and Check again. With phone sign-in already on, the page runs the same check whenever it opens. If the check itself cannot run (the function is not deployed), the switch turns on anyway, with a warning. - A failed sign-in. If the setting goes missing later, the person signing in sees "Sign-in isn't ready on this site yet. Please contact the site's team.", and every admin gets a notification (
admin_sign_in_setup) saying what to fix, at most once a day.
To set it up by hand, with your project id and service account:
gcloud services enable iamcredentials.googleapis.com --project=<project-id>
gcloud iam service-accounts add-iam-policy-binding <service-account> --member=serviceAccount:<service-account> --role=roles/iam.serviceAccountTokenCreator --project=<project-id>
Granting a role needs the Owner or Security Admin role on the project. In the console, the role is added under IAM and admin, IAM.
Use it
The "Phone number or email" field
What a person pastes is cleaned up as it lands, and again on the server: hidden direction marks and zero-width spaces (WhatsApp and contact cards add them), non-breaking spaces, full-width and Devanagari or other Indian digits, a tel: or mailto: link, a name around an address (Asha Rao <asha@example.com>) and the full stop of a sentence. An email is shown in lower case without spaces. A number is read in the country shown beside it (below); an Indian number is shown as 98765 43210, and a number from a country the site does not take is shown in full, such as +447700900123. The cleaning is cleanPasted and cleanEmail in src/shared/utils/identifier.util.ts, mirrored on the server in functions/src/auth/pastedText.ts; both are tested against the same cases.
The country beside a number
As soon as the field starts with a digit, its country appears at the left edge: the flag and the calling code, such as +91. A letter or @ takes it away again, so one field still takes both a number and an email. With one allowed country it is a plain label; with several it is a button that opens the list of allowed countries, the default first (flag, name in the page's language, code), with a search box over eight. The keyboard works too: an arrow opens the list and moves through it, a letter jumps to a name, Enter picks, Escape closes.
- The country starts on the one this device used last (kept in the browser), else the default under Settings, SMS. The browser's language is not used:
en-USis the default nearly everywhere. - A number pasted or autofilled with its own code, such as
+44 7700 900123, moves the code to the chip when the site takes that country, leaving7700900123in the field. While someone types a number starting with+, the chip stays hidden. - A number from a country the site does not take is refused at once, before any code is sent: "This site takes mobile numbers from India only." The server checks the same list.
- The page sends the server the number with its code, such as
+447700900123, so the server never has to guess the country. - A domestic prefix is taken off: the
0of most countries, and the8of Russia, Kazakhstan and Belarus.
The profile's Sign-in methods number box has the same chip, always shown. The chip is the arc-phone-country component and the reading is in src/shared/utils/phone-country.ts; an app can use both for its own number boxes (see Use the custom space, Use core building blocks).
When the field cannot be used, the message under it says what to fix rather than "invalid": a number that is too short or has too many digits, an Indian mobile number that does not start with 6, 7, 8 or 9 (such as 1234567890 beside +91; with several countries it also says to choose the country next to the number), a number from a country the site does not take, a country code that does not look right, an email without a full domain, a word with no @, or a number typed where only email sign-in is on (identifierProblem, messages under member.auth.identifier_error).
A new address gets a 6-digit code by email (template signup_otp_email), valid for 10 minutes, with 5 tries and at least 60 seconds between codes; an address gets at most 5 codes an hour (see Codes, waits and limits). The new account is marked email-verified only when it is created from the browser that entered the code.
Passwords
A new password must be hard to guess. It is refused, with a message that says why, in the member's language, when it is:
- under 8 characters;
- one character repeated (
aaaaaaaa,11111111); - a straight run, forwards or back, of digits, letters or a keyboard row (
12345678,abcdefgh,qwertyui); - one of the most used passwords, or one of their words with digits or symbols after it (
password1,P@ssw0rd!,iloveyou2024); - the person's own: the name in their email, or a part of their name (
Asha@2024for Asha Rao).
That is the strict rule, the default. An app built for casual use can choose simple passwords instead: at least 6 characters, nothing else (see Set up sign-in). The first-run wizard keeps the strict rule for the first admin either way, and the messages say 6 or 8 to match.
The rule is passwordProblem() in src/shared/utils/password-rule.ts, with a mirror for the functions in functions/src/shared/password-rule.ts that a test keeps identical. The page checks it on sign-up, on the profile's change of password, when a phone account adds an email and its first password, on Users, Add User and Edit, and in the first-run wizard; the functions check it again where they set a password themselves (linkEmail, adminCreateUser, adminSetPassword for Users, Edit, and resetPassword after Forgot password) and refuse with the reason weak-password and the problem. Only new passwords are checked: a password set before still signs in, whatever its length. The one place the rule does not reach is Firebase's own reset page, which applies only Firebase's 6-character minimum: a site uses it only while its email engine sends nothing (see Forgot password).
The messages are member.auth.password_error.<problem>; Users, Add User and the wizard say them in English, as the rest of those screens.
Waking the functions early
Each step of a sign-in is its own Cloud Function, and one that has not run for a while takes a few seconds to start. So that the person does not wait for each one in turn, the sign-in page calls the functions its sign-in methods use with { warmUp: true } as it opens, at most once every 5 minutes (SignInService.warmUp(), the list is SIGN_IN_FUNCTIONS). A warm-up call does nothing and returns at once, before any check or rate limit, so it costs a function call and nothing else. The code email is sent by the function that creates the code, not by the email trigger, which would be one more function to start.
The page's settings
What the page offers (phone numbers, Google, the countries, whether sign-ups are open) comes from Settings/users. The page starts from the copy this browser kept from its last visit (arc-sign-in-settings in local storage, written by UserSettingService.readSettings() and by saving Settings, User Settings) and reads the document again in the background, beside the onboarding check rather than after it. A first visit has no copy, and the read can take a few seconds on a cold start. A Continue tapped before the settings are in is held: the button shows as busy, and the step goes on by itself once they arrive. If they cannot be read and there is no copy, or the read takes more than 20 seconds (SETTINGS_WAIT_MS), the page says "We couldn't load the sign-in options. Check your connection and try again." and the next Continue tries again. The server still checks sign-ups and the methods itself, so an old copy can only change what the page shows first.
Phone number
- Numbers can be typed or pasted in any common format, such as
+91 98765-43210,(+44) 7700 900123or atel:link; a number without a country code is read in the country beside it. Numbers from countries the site does not take are refused (by default only India). - SMS codes are 6 digits, valid for 10 minutes, with 5 tries, 60 seconds between codes for the same purpose and at most 5 codes an hour per number.
- PINs are 6 digits. One digit repeated, a straight run (123456, 987654) or a common pattern (121212) is refused, unless the app chose simple PINs, which takes any 6 digits (see Set up sign-in). The 5th wrong PIN in a row locks it, in both.
- An app built on Arc CMS can ask a signed-in person for this PIN at a gate of its own, such as a parent area. Wrong tries there count apart and never lock sign-in (see PINs for your app).
- Forgot PIN, a locked PIN, or a number that never had one: an SMS code, then a new PIN. A reset signs out every other session.
- With the Test SMS provider no SMS is sent: the sign-up page shows the sign-up code. PIN reset codes appear only in SMS Logs, for admins, unless an admin turns on Show PIN reset codes on screen in Settings, SMS (see SMS). Link codes are always in SMS Logs only.
- With the Debug Provider (Log Only) for email no email is sent: the sign-up page shows the email sign-up code too, under the code boxes, and tapping it fills them in. A code for adding an email to an account is in Email Logs only, and so is a password reset code unless an admin turns on Show password reset codes on screen (see Forgot password). See Email engine.
Codes, waits and limits
- One code at a time. Asking again while a code still works sends the same code, so the person never gets two messages with different codes. The resent code has another 10 minutes and keeps its wrong tries; the page says "We sent the same code again." A new code is made only when the last one expired, was used, had 5 wrong tries, was for something else (a PIN reset rather than a sign-up, or adding the number to another account), or was first sent 30 minutes ago. To send it again the server keeps the code sealed (AES-256-GCM, with a key derived from its secret in
_systemthat no browser can read) beside the hash a guess is checked against (functions/src/auth/codeSeal.ts). - Back to the same number. The page remembers each code it asked for, by number or address and purpose, for the code's 10 minutes. Someone who chooses Change number and comes back to the same number goes straight to the code boxes: no second code is sent, the Resend countdown carries on where it was, and Resend comes back once the minute is up. The profile's add-a-number and add-an-email steps do the same.
- The cursor. Every code and PIN step opens with the cursor in the first box. Boxes shown while the code is still on its way take it as soon as they can be typed in, and never from a field someone is typing in (
src/shared/components/code-input/code-input.component.ts). The step that asks for a name and a new PIN or password opens on the name. On an iPhone, Safari opens the keyboard only after a tap, so the cursor is in the box but the keyboard may wait for a tap on it. - A code sent a moment ago. Asking for the same code again less than a minute after it went (after a reload, or from another tab) sends nothing new: the server answers
alreadySentwith the seconds left (wait), and the page shows "We sent a code a moment ago. Enter it, or ask for a new one when the timer ends." and starts the countdown, rather than an error. In test mode the answer carries the code again wherever the page may show it, so it never sits without one. A code that can no longer be used (already verified, or out of tries) is refused withwaitinstead. - The wait is per purpose. A code for one purpose never holds back a code for another: a reset code asked for right after a sign-up code, or a link code, goes at once and replaces it (
codeAskedAgaininfunctions/src/auth/codeSeal.ts). - Only codes actually sent count. The server checks for a code sent a moment ago before the hourly limits, so a request inside the minute uses nothing up. An SMS the provider refuses, or a code email that could not be queued, gives its count back (
releaseRateLimitinfunctions/src/auth/accounts.ts). - The limit says when. At the limit the page says when the person can try again, in their language and clock: "Too many codes. Try again after 5:42 PM."
Messages in the member's language
Every refusal the sign-in functions send a member carries a reason in details.reason, such as wait, too-many-codes or code-wrong, with its numbers beside it (wait in seconds, retryAfter in seconds, remaining tries, the allowed codes). The functions build them with refuse(code, reason, message, extra) in functions/src/auth/refusal.ts. SignInService shows the member string member.auth.server_error.<reason> (dashes become underscores) in the member's language, so every sign-in page and the profile get it through readSignInError(); the server's English shows only for a reason with no string. A test fails when a sign-in function refuses a member without a reason, or when a reason has no English or Hindi string.
Forgot password
Forgot password on the password step works like Forgot PIN, all on the sign-in page, so an installed app never sends the person out to the browser:
- The email engine sends a 6-digit reset code (
requestPasswordReset, templatepassword_reset_otp_email). It has the same 10 minutes, 5 tries, waits and limits as a sign-up code (see Codes, waits and limits), and it goes through the email engine, so it is in Email Logs and uses the site's sender. - The person enters the code in the code boxes ("Reset your password").
verifySignupOtpchecks it with the purposeresetand hands back a ticket: a reset code never passes as a sign-up code, nor the other way round. - They choose a new password ("Choose a new password"). The page checks it against the password rule, and
resetPasswordchecks it again with the person's name and email from their record, sets it with the Admin SDK, uses the code up and signs out every other session. The page then signs in with the new password, as at the password step.
Who can reset here: an account of this site, including a login shared with a host app (authOwner: shared), whose new password then works in that app too. A login the host app owns (authOwner: host), or one with no Arc CMS record, is told "Reset your password in the app you signed up with." (reason host-account). A locked app account and a blocked one are refused as everywhere else.
When the email engine sends nothing (email switched off, no provider, the address suppressed), requestPasswordReset answers sent: false and the page falls back to Firebase's own reset email (sendPasswordResetEmail, in the member's language where Firebase has a template for it). Its link opens Firebase's reset page, which applies only Firebase's 6-character minimum. Set up a real email provider so that every reset goes through Arc CMS.
To test resets without an inbox, choose the Debug Provider (Log Only) in Settings, Email Settings. The reset code is then in Email Logs, and the page says so. To have the page show it, as it shows a sign-up code, turn on Show password reset codes on screen beside the provider (stored as Settings/email.showResetLinks). The switch is off by default and says why: anyone who knows an email could then reset its password. Leaving the Debug Provider turns it off. Only the server reads it (resetCodesShown in functions/src/auth/signupOtp.ts).
While a step runs
- Busy until the page moves on. From the tap on Create Account, Sign in, Save PIN, a PIN or Google until the page has moved on, every button stays busy, though the sign-in itself has finished and the person's record is still on its way: nothing can be tapped twice. A failed attempt, or a move a guard refuses, gives the buttons back; so does a record that has not arrived after 20 seconds (
LEAVING_LIMIT_MS). - No email-only words on a first visit. Until the settings say whether phone sign-in is on, the label above the box, the line under the title and the placeholder keep their room but stay empty, so a site with phone sign-in never shows "Email" first. Settings that cannot be read leave the page asking for an email, as before.
A first-time Google user gets a record straight from their verified Google profile, with no form. If the address already has a password account, the person is asked for the password once and Google is then connected to that account.
Profile, Sign-in methods
On the profile page (/profile, or /user/profile in the member area) a signed-in person can add or change their email or, with phone sign-in on, their phone number (each with a code), change their PIN, and connect Google. A password or PIN is asked for only when the account has none yet.
Moving an email or number to another account
When the email or number someone adds is already on another account, the page says so, and verifying the code moves it. The other account keeps all its data:
- If it still has a way to sign in, its owner gets an in-app notification that the email or number moved.
- If it has none left, it is detached: kept, blocked from signing in, listed under Users, Detached accounts, and the admins are alerted.
- An email that moves is also freed on the old Firebase Auth account, together with its password and Google sign-in, so they cannot reopen the old account.
- Every move is recorded in
account_transfers. - A sign-in that belongs to another app sharing the Firebase project is never changed (see Sharing a Firebase project).
Extend it
- Send people to sign in and back: link to
/signup?redirect=/your-page. - Where people land by role:
src/custom/home.ts, described in Set up sign-in. - The sign-up code email is the
signup_otp_emailtemplate, and the reset code email ispassword_reset_otp_email(see Email engine).
A new sign-in method (another provider) is a change to core, not something an app adds.
What it stores
| Where | What |
|---|---|
users/{docId} | email, emailVerified, phone (E.164) and phoneVerified, and by (how the account was made). |
email_lookup/{sha256(email)} | Whether an address is registered. |
phone_index/{sha256(number)} | Which record a number belongs to. |
signup_otps, phone_otps | Email and SMS codes, hashed, with their purpose (signup, reset, link). |
auth_pins/{uid} | PIN hashes (scrypt with a salt and a server secret kept in _system/pin_pepper), and the lock. |
_rate_limits | Counters for the limits above, per caller, address and number. |
account_transfers | Every email or number moved between accounts. |
Settings/users | isSignupEnabled, defaultRole, phoneSignIn, googleSignIn, and copies of the SMS countries that Settings, SMS writes: phoneCountry (the default, such as IN), phoneCountryCode (its code, 91) and phoneCountries (the allowed ones). |
Cloud Functions (callables, deployed as arccms-<name>):
| Function | Does |
|---|---|
checkEmailAccount | Is this email registered, new, or a sign-in with no access to this site? |
requestSignupOtp, verifySignupOtp | The email sign-up code; verifySignupOtp also checks reset and link codes. |
createAccountRecord | Creates the record after an email sign-up. |
requestPasswordReset, resetPassword | Forgot password: the reset code, then the new password (see Forgot password). |
ensureGoogleAccount | Creates the record for a first Google sign-in. |
checkPhoneAccount, requestPhoneOtp, verifyPhoneOtp, completePhoneSignup, signInWithPin, resetPin, setPin | Phone sign-in. Built only with the sms feature. |
checkIdentifierForLink, requestEmailLinkOtp, linkEmail, linkPhone | Adding or moving an email or number on the profile page. |
refreshMyClaims | Puts the Arc CMS claims back on the caller's token when they are missing. |
The onUserCreated trigger fills email_lookup and tells Firebase Auth the email is verified when the sign-up code proved it, so a later Google sign-in keeps the password. See Cloud Functions.
Who can do what
- The sign-in functions need no account, since people call them to sign in. Codes, PINs and requests have the limits under Use it.
- A person cannot create their own
usersrecord, and cannot change their ownemail,phoneorphoneVerified; only the server does, after a code. They may only clearemailVerified. signup_otps,phone_otps,phone_index,auth_pinsand_rate_limitsare closed to every browser.- Admins can read
account_transfersand SMS Logs, and are the only ones who read or writeSettings/sms.Settings/usersis public to read and admin only to write.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| "This way of signing in is not turned on for this site." | The Firebase provider is off. Turn on Email/Password or Google in the Firebase console. |
| "Phone sign-in is not turned on for this site." | Turn on Phone number under Settings, User Settings (it needs the sms feature). |
| "Google sign-in is not turned on for this site." | Turn on Google under Settings, User Settings, as well as in the Firebase console. |
| "New accounts can't be created on this site right now." | Sign-ups are off. Turn them on, or add the person under Users. |
| "This account doesn't have access to this site." | The address has a Firebase sign-in but no Arc CMS record, such as another app's user in a shared project. An admin adds them under Users. |
| A number is refused as not allowed ("This site takes mobile numbers from India only.") | Its country is not under Countries in Settings, SMS. Add it there. |
| "We couldn't load the sign-in options. Check your connection and try again." | The page could not read Settings/users and this browser has no copy of it: the device is offline, or the read was refused. Check the connection; if it is fine, check that the rules let anyone read Settings/users. See The page's settings. |
| Forgot password says "We've emailed a link to reset your password", not a code | The email engine sent nothing (email off, or no provider), so Firebase sent its own reset email, which never reaches Email Logs. Turn email on with a provider (see Forgot password). |
| "Reset your password in the app you signed up with." | The address is a login of a host app sharing the Firebase project, which owns its password. The person resets it in that app (see Forgot password). |
| A new password is refused ("That password is one of the most used, so it is easy to guess.") | It fails the password rule: too short, one character repeated, a straight run, a common password, or the person's own name or email (see Passwords). Choose another. |
| No SMS arrives | The SMS provider is Test, which sends nothing. Look in SMS Logs, or switch the provider (see SMS). |
| The first sign-in after a quiet spell is slow | The functions were not running and had to start. The page starts them as it opens, so the wait is shortest when the person opens it before typing. A function added to a sign-in step belongs in SIGN_IN_FUNCTIONS in src/app/pages/(auth)/sign-in.service.ts, with if (isWarmUp(request)) return WARM; as the first line of its handler; a test checks both. |
| "Too many codes. Try again after 5:42 PM." | The number or address had 5 codes in the last hour. Wait until that time, or use the last code if it has not expired. |
| "Too many tries. Reset your PIN with a code." | The PIN is locked after 5 wrong tries. Use Forgot PIN. |
| "Sign-in isn't ready on this site yet. Please contact the site's team." | The functions cannot sign the sign-in token: the Token Creator role or the IAM Service Account Credentials API is missing. Open Settings, User Settings: it shows the fix for this project. See Google Cloud setup for phone sign-in. |
| The Phone number switch will not turn on | The same setup is missing, and the page shows how to fix it. After fixing it, click Check again, then turn the switch on. |