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.
| Step | What happens |
|---|---|
| Browser | createTouchqueWeb({ 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-in | Your 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-ins | POST /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. |
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.
| Field | Type | Description |
|---|---|---|
externalUsername | string | Your user ID Required |
type | string | The action (default "LOGIN"). A critical action always asks. |
clientIp | string | The end user's public IP, compared with the networks of their earlier approved sign-ins. Without it the phone is asked. |
userAgent | string | The end user's browser |
browserProof | string | The X-Touchque-Browser value, passed through untouched. Missing or invalid is not an error: it is an unknown browser. |
maxAgeSeconds | integer ≥ 0 | Your own limit on the age of the last phone approval in this browser. 0 = always ask. Touchque never goes beyond 7 days. |
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.- 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
shadow mode (silentMode), prompt is always true and the reasons say what would have happened.| code | Meaning |
|---|---|
known_browser | This browser proved its device-bound key |
recent_approval | Approved on the phone in this browser within the time limit |
same_phone | The phone linked now is the one that approved this browser |
familiar_network | Country and network seen in earlier approved sign-ins |
no_risk_signal | No risk signal |
| code | Meaning |
|---|---|
no_browser_key | No valid browser key proof: a private window, another browser, browserKey off, or a stale or reused proof |
new_browser | This browser has not been approved on the phone for this account |
approval_too_old | The last phone approval in this browser is older than the limit (maxAgeSeconds, at most 7 days) |
policy_every_sign_in | maxAgeSeconds is 0: every sign-in asks |
other_phone | The phone to approve is not the one that approved this browser |
new_country | First approved sign-in from this country |
new_network | First approved sign-in from this network |
network_unknown | Country or network could not be compared with earlier sign-ins (send clientIp) |
hosting_network | Sign-in from a hosting, VPN or Tor network |
critical_action | The action is critical |
sensitive_action | The action changes something important |
risk_signal | A risk signal (named in text) |
push_fatigue | Unanswered or rejected approvals recently |
fraud_report | A recent "this wasn't me" or fraud report |
new_factor | A sign-in factor or the password changed in the last 24 hours |
vishing_shield | A call or coaching was detected around a recent approval |
checks_incomplete | Not every check could run |
not_a_sign_in | Only the first assessment of a sign-in can be silent |
silent_mode_shadow | Silent sign-ins are measured, not used yet (shadow mode) |
phone_not_linked | No phone is linked to this account |
frozen | Approvals for this account are frozen |
phone_keys_lost | The linked phone lost its approval keys |
passkey_required_by_policy | Your policy requires a passkey for this action |
policy_always_challenge | Your policy asks for a number-matched approval every time |
step_up_pending | An earlier approval asked for extra verification |
access_policy | One of your access or country rules applies |
engine_unavailable | The 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".
| Endpoint | Result |
|---|---|
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". |
| SDK | Option | Where |
|---|---|---|
| Node | silentSignIn: true | { maxAgeSeconds } | requireTouchque, withTouchque, touchqueRouter, runGuard |
| Python | silent_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 |
| Go | SilentSignIn: true, SilentSignInMaxAgeSeconds | RequireOptions, RelayOptions, GuardInput |
| .NET | SilentSignIn = new() | new() { MaxAgeSeconds = n } | RequireTouchque, RelayOptions, GuardInput |
| Java | silentSignIn(Guard.SilentSignIn.on() | new Guard.SilentSignIn(n)) | TouchqueGuardOptions, RelayOptions, Guard.Input |
| Ruby | silent_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.
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();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'# Flask (Django: require_touchque, FastAPI: RequireTouchque take the same option)
@app.post('/login/2fa')
@require_touchque('LOGIN', user=lambda: session.get('password_ok'),
silent_sign_in=True) # or {'max_age_seconds': 8 * 3600}
def login_2fa():
# g.touchque['assurance']['method'] == 'silent' → no push was sent
return jsonify(ok=True)
# touchque_relay_blueprint(tq, ..., silent_sign_in=True) → on_authenticated: via == 'silent'// Laravel — a service provider
$this->app->bind(\Touchque\Laravel\TouchqueMiddleware::class, fn ($app) => new \Touchque\Laravel\TouchqueMiddleware(
$app->make(\Touchque\Touchque::class),
['silentSignIn' => true] // or ['maxAgeSeconds' => 8 * 3600]
));
// $request->attributes->get('touchque')['assurance']['method'] === 'silent'
// TouchqueRelay: ['silentSignIn' => true] → onAuthenticated: $info['via'] === 'silent'mux.Handle("/login/2fa", touchque.Require(tq, "LOGIN", http.HandlerFunc(signIn), touchque.RequireOptions{
User: func(r *http.Request) string { return passwordVerifiedUser(r) },
SilentSignIn: true, // your own limit: SilentSignInMaxAgeSeconds (*int)
}))
func signIn(w http.ResponseWriter, r *http.Request) {
approval, _ := touchque.ApprovalFromContext(r.Context())
if approval.Assurance != nil && approval.Assurance.Method == "silent" {
// no push was sent; approval.Reasons say why
}
}
// RelayOptions{..., SilentSignIn: true} → OnAuthenticated: info.Via == touchque.ViaSilentapp.MapPost("/login/2fa", SignIn)
.RequireTouchque("LOGIN", o =>
{
o.User = c => c.HttpContext.Session.GetString("password_verified_user");
o.SilentSignIn = new(); // or your own limit: new() { MaxAgeSeconds = 8 * 3600 }
});
// http.GetTouchqueApproval()!.Assurance?.Method == "silent" → no push was sent
// MapTouchqueRelay(..., new RelayOptions<HttpContext> { ..., SilentSignIn = new() }) → OnAuthenticated: info.Via == "silent"@PostMapping("/login/2fa")
@RequireTouchque("LOGIN")
ResponseEntity<Void> signIn(Approval approval) {
// approval.assurance().method().equals("silent") → no push was sent
return ResponseEntity.ok().build();
}
@Bean
TouchqueGuardOptions login() {
return TouchqueGuardOptions.forAction("LOGIN")
.user(c -> (String) c.request().getSession().getAttribute("password_verified_user"))
.silentSignIn(Guard.SilentSignIn.on()); // or your own limit: new Guard.SilentSignIn(8 * 3600)
}
// RelayOptions<HttpServletRequest>(...).silentSignIn(Guard.SilentSignIn.on()) → onAuthenticated: info.via().equals("silent")class TwoFactorController < ApplicationController
include Touchque::Rails::Guard
require_touchque "LOGIN", only: :create, user: -> { session[:password_verified_user] },
silent_sign_in: true # or { max_age_seconds: 8 * 3600 }
def create
# touchque_approval["assurance"]["method"] == "silent" → no push was sent
head :ok
end
end
# Touchque::Rack::Relay.new(..., silent_sign_in: true) → on_authenticated: info["via"] == "silent"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 onebrowser_proof = request.headers.get('X-Touchque-Browser')
risk = tq.risk.check(user, type='LOGIN', client_ip=ip, browser_proof=browser_proof)
if risk['prompt'] is False:
return open_session(user) # risk['reasons'] say why
step = tq.start('LOGIN', user, ip=ip, browser_proof=browser_proof)
browsers = tq.risk.browsers(user)['browsers']
tq.risk.forget_browsers(user) # all; or thumbprint=... for one$browserProof = $request->header('X-Touchque-Browser');
$risk = $tq->risk->check([
'externalUsername' => $user, 'type' => 'LOGIN', 'clientIp' => $request->ip(), 'browserProof' => $browserProof,
]);
if ($risk['prompt'] === false) {
return openSession($user); // $risk['reasons'] say why
}
$step = $tq->start('LOGIN', $user, null, null, $request->ip(), $request->userAgent(), $browserProof);
$browsers = $tq->risk->browsers(['externalUsername' => $user])['browsers'];
$tq->risk->forgetBrowsers(['externalUsername' => $user]); // all; add 'thumbprint' for oneproof := r.Header.Get(touchque.GuardBrowserHeader)
risk, err := tq.Risk.Check(r.Context(), touchque.RiskCheckOptions{
ExternalUsername: user, Type: "LOGIN", ClientIP: ip, UserAgent: r.UserAgent(), BrowserProof: proof,
})
if err == nil && !risk.Prompt {
return openSession(user) // risk.Reasons say why
}
step, err := tq.Start("LOGIN", user, touchque.StartOptions{IP: ip, BrowserProof: proof})
list, err := tq.Risk.Browsers(ctx, touchque.RiskBrowsersOptions{ExternalUsername: user})
_, err = tq.Risk.ForgetBrowsers(ctx, touchque.ForgetBrowsersOptions{ExternalUsername: user}) // all; or Thumbprint for onevar browserProof = http.Request.Headers["X-Touchque-Browser"].ToString();
var risk = await tq.Risk.CheckAsync(new RiskCheckOptions
{
ExternalUsername = user, Type = "LOGIN", ClientIp = http.TouchqueClientIp(),
UserAgent = http.Request.Headers.UserAgent.ToString(), BrowserProof = browserProof,
});
if (!risk.Prompt) return OpenSession(user); // risk.Reasons say why
var step = await tq.StartAsync("LOGIN", new StartOptions { User = user, Ip = http.TouchqueClientIp(), BrowserProof = browserProof });
var browsers = await tq.Risk.BrowsersAsync(user);
await tq.Risk.ForgetBrowsersAsync(user); // all; or a thumbprint for oneString browserProof = request.getHeader("X-Touchque-Browser");
Risk.CheckResult risk = tq.risk().check(new Risk.CheckOptions(user).type("LOGIN")
.clientIp(TouchqueSpring.clientIp(request)).userAgent(request.getHeader("User-Agent")).browserProof(browserProof));
if (!risk.prompt()) return openSession(user); // risk.reasons() say why
Step step = tq.start("LOGIN", new StartOptions(user).ip(TouchqueSpring.clientIp(request)).browserProof(browserProof));
JsonNode browsers = tq.risk().browsers(user);
tq.risk().forgetBrowsers(user, null); // all; or a thumbprint for onebrowser_proof = request.headers["X-Touchque-Browser"]
risk = tq.risk.check(user, type: "LOGIN", client_ip: request.remote_ip, user_agent: request.user_agent, browser_proof: browser_proof)
return open_session(user) if risk["prompt"] == false # risk["reasons"] say why
step = tq.start("LOGIN", user, ip: request.remote_ip, browser_proof: browser_proof)
browsers = tq.risk.browsers(user)["browsers"]
tq.risk.forget_browsers(user) # all; or thumbprint: for onecurl -X POST https://api.touchque.com/risk/check \
-H "x-api-key: tq_abc123" -H "x-timestamp: $TS" -H "x-nonce: $NONCE" -H "x-signature: $SIG" \
-H "Content-Type: application/json" \
-d '{"externalUsername":"user@company.com","type":"LOGIN","clientIp":"203.0.113.7","browserProof":"'"$PROOF"'"}'{
"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"
}