Documentation

Silent sign-ins

Stop asking the phone again and again when nothing about a sign-in is new. The browser holds a device-bound key, and Touchque's risk engine decides: the phone is asked only when something is new (a new browser, phone, country or network, a critical action, any risk signal). Your server skips the push only when Touchque says so.

StepWhat happens
BrowsercreateTouchqueWeb({ browserKey: true }) (@touchque/web 2.2.0) makes one ECDSA P-256 key for your site with WebCrypto, non-extractable, kept in IndexedDB, and sends a fresh signed proof of it in the X-Touchque-Browser header. Script can use the key, never read it out.
First sign-inYour server sends the push with the proof (login.request({ …, browserProof }); the guard does it for you). Once the user approves on the phone, Touchque knows the browser.
Next sign-insPOST /risk/check (tq.risk.check) with a fresh proof. prompt: false: nothing is new, sign the user in with no push. Anything else: the usual push.
POST/risk/check

Same API key and quota as /login/request. Sends nothing to the phone. Answers 200 { prompt, reasons: [{ code, text }], assurance: "high" | "normal" | "low", silentMode: "enforce" | "shadow", caseCode: "K7P-2QX" }. caseCode is this answer's "Why?" case code: show it when the phone is asked, and your team opens the decision under Logs → Look up a case in this dashboard.

FieldTypeDescription
externalUsernamestringYour user ID Required
typestringThe action (default "LOGIN"). A critical action always asks.
clientIpstringThe end user's public IP, compared with the networks of their earlier approved sign-ins. Without it the phone is asked.
userAgentstringThe end user's browser
browserProofstringThe X-Touchque-Browser value, passed through untouched. Missing or invalid is not an error: it is an unknown browser.
maxAgeSecondsinteger ≥ 0Your own limit on the age of the last phone approval in this browser. 0 = always ask. Touchque never goes beyond 7 days.
Fail closed. Skip the push only when the answer is exactly prompt: false (a boolean). An error, a timeout or an unreadable answer means: send the push as usual. The SDKs read the answer this way, and their guards never approve on an error.
Never silent: these always ask the phone
  • The first sign-in in a browser, and any request without a valid key proof (private windows, other browsers, pages without browserKey)
  • Critical actions, and actions that change something important
  • A new country or network, a hosting / VPN / Tor network, or no clientIp to compare
  • A phone other than the one that approved the browser, or a last phone approval older than maxAgeSeconds or 7 days
  • Any risk signal: recent unanswered or rejected pushes, a fraud or "this wasn't me" report, a factor or password changed in the last 24 hours
  • Vishing Shield: an approval given during a call and not re-confirmed (12 hours), or an approval taken back or rejected as coached (7 days)
  • A frozen account, a phone that lost its keys, passkey-only or always-number-match policies, a pending step-up, an access or country rule
  • Any check that could not run, and shadow mode
A browser becomes known only through a real second factor on a request that carried its proof: a push confirmed on the phone (not one approved during a call, which waits to be re-confirmed), a passkey, or that request's offline QR code. A typed time-based code, a recovery code or a silent sign-in never teaches Touchque a browser. In shadow mode (silentMode), prompt is always true and the reasons say what would have happened.
Reason codes: not asked (all of them together)
codeMeaning
known_browserThis browser proved its device-bound key
recent_approvalApproved on the phone in this browser within the time limit
same_phoneThe phone linked now is the one that approved this browser
familiar_networkCountry and network seen in earlier approved sign-ins
no_risk_signalNo risk signal
Reason codes: asked (any one of them)
codeMeaning
no_browser_keyNo valid browser key proof: a private window, another browser, browserKey off, or a stale or reused proof
new_browserThis browser has not been approved on the phone for this account
approval_too_oldThe last phone approval in this browser is older than the limit (maxAgeSeconds, at most 7 days)
policy_every_sign_inmaxAgeSeconds is 0: every sign-in asks
other_phoneThe phone to approve is not the one that approved this browser
new_countryFirst approved sign-in from this country
new_networkFirst approved sign-in from this network
network_unknownCountry or network could not be compared with earlier sign-ins (send clientIp)
hosting_networkSign-in from a hosting, VPN or Tor network
critical_actionThe action is critical
sensitive_actionThe action changes something important
risk_signalA risk signal (named in text)
push_fatigueUnanswered or rejected approvals recently
fraud_reportA recent "this wasn't me" or fraud report
new_factorA sign-in factor or the password changed in the last 24 hours
vishing_shieldA call or coaching was detected around a recent approval
checks_incompleteNot every check could run
not_a_sign_inOnly the first assessment of a sign-in can be silent
silent_mode_shadowSilent sign-ins are measured, not used yet (shadow mode)
phone_not_linkedNo phone is linked to this account
frozenApprovals for this account are frozen
phone_keys_lostThe linked phone lost its approval keys
passkey_required_by_policyYour policy requires a passkey for this action
policy_always_challengeYour policy asks for a number-matched approval every time
step_up_pendingAn earlier approval asked for extra verification
access_policyOne of your access or country rules applies
engine_unavailableThe risk engine could not be asked

text is English; show it, or translate the code. New codes can be added in minor versions: treat an unknown code as "asked".

Known browsers
EndpointResult
GET /risk/browsers?externalUsername={ browsers: [{ thumbprint, label, createdAt, lastMfaAt, lastMfaMethod, lastSeenAt, lastSilentAt }] }
POST /risk/browsers/forget{ externalUsername, thumbprint? } → { forgotten: n }. No thumbprint forgets them all: do it after a password reset or a "sign out everywhere".
Guard option (default off)
SDKOptionWhere
NodesilentSignIn: true | { maxAgeSeconds }requireTouchque, withTouchque, touchqueRouter, runGuard
Pythonsilent_sign_in=True | {'max_age_seconds': n}require_touchque (Flask, Django), RequireTouchque (FastAPI), relays, run_guard
PHP'silentSignIn' => true | ['maxAgeSeconds' => n]TouchqueMiddleware, TouchqueRelay, RelayHandler, Guard::run
GoSilentSignIn: true, SilentSignInMaxAgeSecondsRequireOptions, RelayOptions, GuardInput
.NETSilentSignIn = new() | new() { MaxAgeSeconds = n }RequireTouchque, RelayOptions, GuardInput
JavasilentSignIn(Guard.SilentSignIn.on() | new Guard.SilentSignIn(n))TouchqueGuardOptions, RelayOptions, Guard.Input
Rubysilent_sign_in: true | { max_age_seconds: n }require_touchque (Rails), Touchque::Rack::Relay

With it on, a new attempt that carries X-Touchque-Browser first calls risk.check. prompt: false approves it at once with no push: assurance.method "silent", confirmedVia "SILENT", requestId "silent", reasons; the relays call onAuthenticated with via "silent". Otherwise, and on any error in the check, the usual push starts with the proof attached, so its approval makes the browser known. Polling with the token never checks again. A relay on another origin must allow X-Touchque-Browser in Access-Control-Allow-Headers.

Browser
import { createTouchqueWeb, browserKey } from '@touchque/web';

// Guarded requests and the classic 2FA login carry X-Touchque-Browser
const tq = createTouchqueWeb({ baseUrl: '/touchque', browserKey: true });

const result = await tq.classic2fa.login({ onStep });
if (result.assurance?.method === 'silent') {
  // a known browser: no push was sent
}

// Shared computer / sign out everywhere: delete this browser's key
await tq.browserKey.forget();

// Without createTouchqueWeb: the header value, or null when unavailable
const proof = await browserKey.proof();
Server: the guard option
app.post('/login/2fa',
  requireTouchque('LOGIN', {
    user: (req) => req.session.passwordVerifiedUser,
    silentSignIn: true, // or your own limit: { maxAgeSeconds: 8 * 3600 }
  }),
  (req, res) => {
    // req.touchque.assurance.method === 'silent' → no push was sent
    // req.touchque.reasons → [{ code: 'known_browser', text: '…' }, …]
    res.json({ ok: true });
  });

// touchqueRouter(tq, { …, silentSignIn: true }) → onAuthenticated: via === 'silent'
Server: without the guard
const browserProof = req.get('x-touchque-browser');
const risk = await tq.risk.check({
  externalUsername: user, type: 'LOGIN', clientIp: req.ip, userAgent: req.get('user-agent'), browserProof,
});
if (risk.prompt === false) return openSession(user); // risk.reasons say why

// Anything else: the usual push, proof attached (the approval makes the browser known)
await tq.login.request({ externalUsername: user, type: 'LOGIN', clientIp: req.ip, browserProof });

const { browsers } = await tq.risk.browsers({ externalUsername: user });
await tq.risk.forgetBrowsers({ externalUsername: user }); // all; or { thumbprint } for one
Response 200
{
  "prompt": false,
  "reasons": [
    { "code": "known_browser", "text": "This browser proved its device-bound key" },
    { "code": "recent_approval", "text": "Approved on the phone in this browser 3 hours ago" },
    { "code": "same_phone", "text": "The same phone approved this browser" },
    { "code": "familiar_network", "text": "Country and network seen in earlier approved sign-ins" },
    { "code": "no_risk_signal", "text": "No risk signal" }
  ],
  "assurance": "high",
  "silentMode": "enforce"
}