Passkeys: dialog, autofill, migration
Passkey-first MFA in a few lines: a ready-made approval dialog for any guarded action, passkeys offered in the email field's autofill, and a once-per-browser "Add a passkey" offer that moves password users over.
<touchque-approval> (any page) and <TouchqueApproval> (React) call a route guarded by any server SDK and open only when it asks for an approval. They show the number to pick on the phone, the first-time link QR, the offline QR and code, the passkey step and refusals with their case code. Restyle with --tq-approval-* CSS variables; every text can be replaced.
The browser lists the user's passkeys for your site in the email field's suggestions (WebAuthn conditional mediation); picking one signs in with no email typed. It never opens a modal: browsers without it show nothing, so keep your normal form. The relay is asked for options with no email, which accept any passkey of your site, and the passkey picked names the account.
touchqueRouter runs it and passkeys.upgrade() shows the number through onStep. Without a phone, prove the person yourself (the password just typed, or a sign-in from the last few minutes) before your relay's register route answers.| # | Step | How |
|---|---|---|
| 1 | Turn on passkeys for your site | Dashboard → App user policy → "Passkeys on your site": your domain (RP ID) and sign-in page origins, then "Passwordless sign-in". Nothing changes for users yet. |
| 2 | Mount the relay | touchqueRouter (Node), the Flask/Django/FastAPI blueprint, TouchqueRelay::routes (Laravel), NewRelayHandler (Go), MapTouchqueRelay (ASP.NET Core), the Spring Boot starter's relay or Touchque::Rack::Relay (Rails, Rack), with getUserId for the signed-in user. It serves sign-in, autofill, registration and approvals. |
| 3 | Add autofill to the sign-in form | autocomplete="username webauthn" on the email field and passkeys.autofill() when the form shows. Users who have a passkey pick it from the field; everyone else signs in as before. |
| 4 | Offer "Add a passkey" after password sign-ins | shouldOfferPasskey() after a password or email-link sign-in, then passkeys.upgrade() or dismissPasskeyOffer(). Once per browser; "Not now" is remembered there. |
| 5 | Watch adoption | PASSKEY_REGISTERED / PASSKEY_DELETED webhooks, or an Action on passkey.added, tell you who moved; GET /webauthn/credentials lists a user's passkeys. |
| 6 | Then require it where it matters | Require a passkey for critical actions (policy) or for admins (the require_passkey_for_role Action template). Keep the password and the phone as recovery until most users have two passkeys; Never Locked Out covers a lost phone. |
@touchque/web 2.2.0, @touchque/react 2.1.0, server SDKs 5.2.0: the relay's usernameless options came in 4.0.0, and the names on this page (requireTouchque, touchqueRouter) are those of 5.0 (4.x spelled them TouchQue).
import { touchqueRouter, requireTouchque } from '@touchque/node';
// The relay the browser calls: passkey sign-in (autofill too), registration, approvals
app.use('/touchque', touchqueRouter(tq, {
getUserId: (req) => req.session.user?.email, // the signed-in user, for registering passkeys
getLoginUser: (req) => req.session.passwordOk, // the user who passed your password step
onAuthenticated: (req, res, { externalUsername }) => { req.session.user = { email: externalUsername }; },
}));
// An action that needs the phone: one line
app.post('/api/transfer', requireTouchque('SEND_MONEY'), sendMoney);from touchque.contrib.flask import touchque_relay_blueprint, require_touchque
app.register_blueprint(touchque_relay_blueprint(
tq,
get_user_id=lambda req: session.get('user'),
get_login_user=lambda req: session.get('password_ok'),
), url_prefix='/touchque')
@app.post('/api/transfer')
@require_touchque('SEND_MONEY')
def transfer(): ...// routes/web.php (Laravel)
TouchqueRelay::routes(app('router'), 'touchque');
// routes/api.php
Route::post('/transfer', [TransferController::class, 'store'])
->middleware('touchque:SEND_MONEY');mux.Handle("/touchque/", http.StripPrefix("/touchque", touchque.NewRelayHandler(tq, touchque.RelayOptions{
UserID: func(r *http.Request) string { return currentUser(r) },
})))
mux.Handle("/api/transfer", touchque.Require(tq, "SEND_MONEY", http.HandlerFunc(transferHandler), touchque.RequireOptions{
User: func(r *http.Request) string { return currentUser(r) }, // required
}))app.MapTouchqueRelay("/touchque", new RelayOptions<HttpContext>
{
GetUserId = http => http.User.TouchqueUser(),
});
app.MapPost("/api/transfer", SendMoney).RequireAuthorization().RequireTouchque("SEND_MONEY");@Bean // the relay, served at /touchque
RelayOptions<HttpServletRequest> touchqueRelay() {
return new RelayOptions<HttpServletRequest>(TouchqueSpring::user);
}
@PostMapping("/api/transfer")
@RequireTouchque("SEND_MONEY")
Receipt sendMoney(@RequestBody Transfer t) { … }# config/routes.rb
mount Touchque::Rack::Relay.new(get_user_id: ->(req) { req.session["user"] }) => "/touchque"
# app/controllers/transfers_controller.rb
include Touchque::Rails::Guard
require_touchque "SEND_MONEY", only: :create<!-- Any page, no framework -->
<touchque-approval base-url="/touchque"></touchque-approval>
<script type="module">
import { defineTouchqueApproval } from '@touchque/web';
defineTouchqueApproval();
const dialog = document.querySelector('touchque-approval');
const res = await dialog.fetch('/api/transfer', { method: 'POST', body });
</script>import { useTouchqueApproval, TouchqueApproval } from '@touchque/react';
const approval = useTouchqueApproval({ baseUrl: '/touchque' });
<button onClick={() => approval.fetch('/api/transfer', { method: 'POST', body })}>Send</button>
<TouchqueApproval approval={approval} /><input type="email" name="email" autocomplete="username webauthn">
<script type="module">
import { createTouchqueWeb } from '@touchque/web';
const tq = createTouchqueWeb({ baseUrl: '/touchque' });
const result = await tq.passkeys.autofill({ input: 'input[name=email]' });
if (result?.ok) location.href = '/home'; // null: no autofill here, keep the form
</script>const autofill = usePasskeyAutofill({ baseUrl: '/touchque' }, {
onResult: (r) => r.ok && navigate('/home'),
});
<input type="email" name="email" {...autofill.inputProps} />import { createTouchqueWeb, shouldOfferPasskey, dismissPasskeyOffer } from '@touchque/web';
const tq = createTouchqueWeb({ baseUrl: '/touchque', credentials: 'include' });
// Right after a password or email-link sign-in, before you leave the page:
if (await shouldOfferPasskey({ user: email })) {
showOffer({
add: () => tq.passkeys.upgrade({ user: email, onStep: showPhoneNumber }), // a linked phone approves first
notNow: () => dismissPasskeyOffer(email), // remembered in this browser
});
}const offer = usePasskeyUpgrade({ baseUrl: '/touchque', credentials: 'include' }, user.email);
{offer.status === 'offer' && (
<Banner onAdd={() => offer.upgrade()} onLater={offer.dismiss}
number={offer.step?.number} />
)}