PINs for your app

A 6-digit PIN lets people sign in on a shared device without an email or a phone: staff at a front desk, a family tablet. Your own function checks it with Arc CMS's PIN code, the same hashing, rules and lockout phone sign-in uses, and hands back a sign-in token. To ask a signed-in person for the PIN they already sign in with by phone, at a gate such as a parent area, see Check the phone sign-in PIN at a gate.

A PIN store

import { createPinStore } from '../app-kit/index.js';

const pins = createPinStore('kiosk', { maxAttempts: 5 });

The namespace keeps kinds of PIN apart: a person can have a kiosk PIN and a staff PIN, each with its own lock. It is lower case letters, digits, - and _, 2 to 31 characters, starting with a letter. maxAttempts is 1 to 20, default 5.

strength is how hard a new PIN must be: leave it out for the app's choice in src/custom/sign-in.ts (see Set up sign-in), or name one for this store. A casual app can keep a strict staff PIN with createPinStore('staff', { strength: 'strict' }). Under 'simple' any 6 digits are taken; the lockout is the same in both. Your own code can ask the same question with pinTooEasy(pin, strength?).

CallDoes
pins.set(uid, pin)Sets or replaces the PIN, which also unlocks it. Refuses one that is not exactly 6 digits, or, under the strict rule, too easy to guess (111111, 123456, common patterns), with a message the person can act on.
pins.check(uid, pin){ ok: true }, or { ok: false, reason } with none (no PIN), locked, or wrong and remaining tries. A wrong PIN counts; the last allowed one locks it. A correct PIN resets the count.
pins.has(uid)Whether the person has a PIN here.
pins.clearLock(uid)Unlocks without changing the PIN, for example when a manager vouches for the person.
pins.remove(uid)Removes the PIN.

The uid is the person's Firebase Auth uid, such as the one createAppAccount() returns (see App accounts).

Where PINs are kept

In app_pins/<namespace>__<uid>, which Arc CMS's rules close to every browser. Only your functions reach it, through the store. A PIN is never stored: only a salted, peppered hash, as for phone sign-in. It is a separate collection from phone sign-in's auth_pins, so your PIN and a person's phone PIN never touch. When an account is deleted, its PINs in every namespace go with it.

There is no timed unlock and no reset by code: unlocking is your app's decision, with clearLock() or a new set(). Arc CMS's reset by SMS code is for phone sign-in's PINs only.

Rate limits

Lockout protects one person's PIN. A rate limit protects your function from someone trying many people. Count each attempt against a key with consumeRateLimit(key, max, windowMs, message); it refuses with resource-exhausted and your message once max is reached in the window. The window starts with the first use of the key. The refusal's details say why and when it reopens: { reason, retryAfter }, with reason from an optional fifth argument (default too-many-attempts) and retryAfter in seconds, so your page can say "Try again after 5:42 PM" in the member's language.

KeyFor
hashedKey('kiosk-device', deviceId)Something your app knows, such as the device. The value is hashed, so no raw id is stored, and the scope keeps it apart from Arc CMS's own keys.
callerKey(request)The caller's address, hashed. It takes the address Google's front end saw, right for a callable the browser calls directly. Behind a Firebase Hosting rewrite, the last address is Hosting's: pass { trustedProxies: 1 }, or count by a key of your own instead.

Use both: a per-device or per-address limit stops one place from trying many people, and the per-PIN lockout stops anyone from trying many PINs on one person.

Sign in with a PIN: a complete callable

// functions/src/custom/pinSignIn.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { consumeRateLimit, createPinStore, hashedKey, issueSignInToken } from '../app-kit/index.js';

const pins = createPinStore('kiosk');
const MINUTE = 60 * 1000;

/** Staff sign in at a kiosk: { uid, pin, deviceId } in, a sign-in token out. */
export const signInWithAppPin = onCall(async (request) => {
  const uid = String(request.data?.uid ?? '');
  const deviceId = String(request.data?.deviceId ?? '');
  const tooMany = 'Too many tries. Wait a few minutes.';
  await consumeRateLimit(hashedKey('kiosk-device', deviceId), 30, 10 * MINUTE, tooMany);
  await consumeRateLimit(hashedKey('kiosk-staff', uid), 10, 10 * MINUTE, tooMany);

  const result = await pins.check(uid, request.data?.pin);
  if (result.ok) return { token: await issueSignInToken(uid) };
  if (result.reason === 'wrong') throw new HttpsError('permission-denied', `Wrong PIN. ${result.remaining} left.`);
  if (result.reason === 'locked') throw new HttpsError('permission-denied', 'This PIN is locked. Ask a manager.');
  throw new HttpsError('failed-precondition', 'No PIN is set for you yet.');
});

/** A manager sets or resets a member of staff's PIN. */
export const setStaffPin = onCall(async (request) => {
  // ... check that the caller may do this, for example with isArcAdmin(request.auth?.token) ...
  await pins.set(String(request.data?.uid ?? ''), String(request.data?.pin ?? ''));
  return { ok: true };
});

Add signInWithAppPin and setStaffPin to functions/src/custom/public-callables.txt, deploy them (see Write your own Cloud Functions), and in the browser exchange the token:

import { signInWithCustomToken } from '@angular/fire/auth';

const { token } = (await arcCallable<{ uid: string; pin: string; deviceId: string }, { token: string }>(
  this.functions, 'custom-signInWithAppPin')({ uid, pin, deviceId })).data;
await signInWithCustomToken(this.auth, token);

Say the same thing for a wrong uid as for a wrong PIN if your screen lets people type their own id: otherwise the answer tells a stranger which ids exist. On a kiosk where staff pick their name from a list, that is already known and the plain messages above are fine.

Check the phone sign-in PIN at a gate

A gate inside your app, such as a parent area on a family tablet, can ask the signed-in person for the 6-digit PIN they sign in with by phone, so they have one PIN to remember instead of two. Your function checks it with createPhonePinCheck():

import { createPhonePinCheck } from '../app-kit/index.js';

const parentGate = createPhonePinCheck('parent', { maxAttempts: 5 });
CallDoes
parentGate.has(uid)Whether the person has a phone sign-in PIN, so your page knows whether to offer it.
parentGate.check(uid, pin){ ok: true }, or { ok: false, reason } with none (no phone PIN), locked, or wrong and remaining tries. A wrong PIN counts against this gate only; the last allowed one locks it. A correct PIN resets the count. Anything that is not 6 digits is a wrong PIN.
parentGate.clearLock(uid)Unlocks this gate for the person.

The namespace and maxAttempts follow the same rules as a PIN store's. The uid is the person's Firebase Auth uid.

The check reads the phone PIN's hash and never writes it, and never returns it. Wrong tries are counted in app_phone_pin_tries/<namespace>__<uid>, which Arc CMS's rules close to every browser, never in phone sign-in's own count. So a child guessing at the gate can lock the gate, but never the parent's sign-in, and a correct PIN at the gate does not reset sign-in's count either. Each gate has its own count, apart from a PIN store of the same name. The gate unlocks with clearLock(), or when the person sets a new phone PIN with Forgot PIN on the sign-in page. Setting or changing the phone PIN stays with sign-in. When an account is deleted, its counts in every namespace go with it.

Only people who sign in by phone have a phone PIN. Accounts that sign in by email or Google, and app accounts made with createAppAccount(), have none: has() is false and check() says none. A person who signed in by email and later added a number has one, from the PIN they chose then. Offer such people your own way through the gate, for example a PIN store of your own.

Never use this to sign anyone in. Phone sign-in has its own lockout, reset by SMS code and rate limits, and a gate's count is not one of them: a sign-in built on this check would let anyone who knows a person's uid try their PIN without ever locking their sign-in. Take the uid from request.auth.uid, never from what the browser sends, so a person can only check their own PIN.

// functions/src/custom/parentGate.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { callerKey, consumeRateLimit, createPhonePinCheck, hashedKey } from '../app-kit/index.js';

const parentGate = createPhonePinCheck('parent', { maxAttempts: 5 });
const MINUTE = 60 * 1000;

/** Whether to offer "Use your 6-digit sign-in PIN" at the parent gate. */
export const parentGateOptions = onCall(async (request) => {
  const uid = request.auth?.uid;
  if (!uid) throw new HttpsError('unauthenticated', 'Sign in first.');
  return { phonePin: await parentGate.has(uid) };
});

/** Opens the parent area: { pin } in, { ok: true } out, for the signed-in person only. */
export const openParentGate = onCall(async (request) => {
  const uid = request.auth?.uid;
  if (!uid) throw new HttpsError('unauthenticated', 'Sign in first.');
  const tooMany = 'Too many tries. Wait a few minutes.';
  await consumeRateLimit(hashedKey('parent-gate-ip', callerKey(request)), 30, 10 * MINUTE, tooMany);
  await consumeRateLimit(hashedKey('parent-gate', uid), 10, 10 * MINUTE, tooMany);

  const result = await parentGate.check(uid, request.data?.pin);
  if (result.ok) return { ok: true };
  if (result.reason === 'wrong') {
    throw new HttpsError('permission-denied', `Wrong PIN. ${result.remaining} left.`, { reason: 'wrong', remaining: result.remaining });
  }
  if (result.reason === 'locked') {
    throw new HttpsError('permission-denied', 'Too many wrong PINs. Set a new PIN with Forgot PIN on the sign-in page.', { reason: 'locked' });
  }
  throw new HttpsError('failed-precondition', 'You do not sign in with a PIN.', { reason: 'none' });
});

Add parentGateOptions and openParentGate to functions/src/custom/public-callables.txt and deploy them (see Write your own Cloud Functions). The details carry reason and remaining, so your page can say it in the member's language. The answer opens the gate on the device; data only a parent may change still needs its own rule or function check.