Add Touchque 2FA to your own app (instead of Google Authenticator)
Let your customers approve sign-ins with the Touchque Authenticator app instead of typing Google Authenticator's 6-digit codes. Your password step stays as it is; the second step becomes an approval on the phone. This guide covers the whole path: API key, SDK install, linking the phone, the sign-in request, the result (webhook or status check) and short code.
login.request: a push goes to the phone. 5) Get the result by webhook or with login.status, then open the session.What changes for your users
| Topic | Google Authenticator | Touchque Authenticator |
|---|---|---|
| The second step | The user opens the app, reads a 6-digit code and types it in. | A push arrives on the phone. The user checks it and taps Approve (Face ID or fingerprint). |
| What the user sees | A code and your name. Nothing about this sign-in. | Your brand, what is being approved (a sign-in or a payment with its details), the city and the browser. |
| Someone else tries | A phishing page asks for the code and uses it at once. | The push shows another city or browser; the user taps "This wasn't me". Risky requests ask for number matching or are blocked. |
| Phone without internet | Codes work offline. | An offline QR code or an offline time-based code, if you turn them on. |
| Lost phone | You reset 2FA by hand. | Recovery codes, a new phone moved with colleagues vouching, or a reset by you. |
Your users install Touchque Authenticator on their phone (iPhone or Android) and link it once. Nothing changes in how they sign up or type their password.
Before you start
- In the Touchque dashboard, create a key under Developers → API keys. You get an API key starting with
tq_and an API secret; the secret is shown only once. - Keep both on your server only (
TQ_API_KEY,TQ_API_SECRET). Never put them in the browser or a mobile app: every request is HMAC-signed with the secret. - Pick one stable id per user:
externalUsername(email, user id). Touchque keeps it only as a reference.
1. Install the SDK and set your keys
Pick your server language. Node.js 5.2.0, Python 5.2.0, PHP 5.2.0, Go 5.2.0, .NET 5.2.0, Ruby 5.2.0 and Java 5.2.0 are published. Without an SDK, the REST API works with any language: see Authentication for the request signature.
# Node.js
npm install @touchque/node
# Python
pip install touchque-authenticator
# PHP
composer require touchque-authenticator/php-sdk:^5.0
# Go
go get github.com/Touchque/touchque-go/v5
# .NET
dotnet add package Touchque.AspNetCore
# Ruby
bundle add touchque
# Java
implementation("com.touchque:touchque-spring-boot-starter:5.2.0")
# : not published yet
# .env on your server (never in the browser or the app)
TQ_API_KEY=tq_...
TQ_API_SECRET=...2. Link the user's phone (once)
On your settings page ("Turn on 2FA"), create a secret for the user and show the QR image it comes with. The user opens Touchque Authenticator and scans it, the same way they would add an account to Google Authenticator. The secret is valid for 5 minutes; after that, create a new one. Show the 10 recovery codes once and ask the user to keep them: they are the way in when the phone is gone.
const { Touchque } = require('@touchque/node');
const tq = new Touchque(); // TQ_API_KEY / TQ_API_SECRET from the environment
// Settings page → "Turn on 2FA"
app.post('/2fa/enroll', requireSession, async (req, res) => {
const { qrCodeDataUrl, recoveryCodes, expiresAt } = await tq.auth.generateSecret({
externalUsername: req.user.email,
});
// Show the QR (<img src=qrCodeDataUrl>) and the recovery codes, once
res.json({ qrCodeDataUrl, recoveryCodes, expiresAt });
});
// The page asks this until the phone is linked
app.get('/2fa/enroll/status', requireSession, async (req, res) => {
const user = await tq.auth.getUser({ externalUsername: req.user.email });
res.json({ linked: Boolean(user.used && user.deviceId) });
});using Touchque;
var tq = new TouchqueClient(); // TQ_API_KEY / TQ_API_SECRET from the environment
var secret = await tq.Auth.GenerateSecretAsync(user.Email);
// secret.QrCodeDataUrl → <img>, secret.RecoveryCodes → show once
var u = await tq.Auth.GetUserAsync(user.Email);
var linked = u.Used && u.DeviceId is not null;tq = Touchque::Client.new # TQ_API_KEY / TQ_API_SECRET from the environment
secret = tq.auth.generate_secret(current_user.email)
# secret["qrCodeDataUrl"] → <img>, secret["recoveryCodes"] → show once
u = tq.auth.get_user(current_user.email)
linked = u["used"] && !u["deviceId"].nil?var tq = new TouchqueClient(); // TQ_API_KEY / TQ_API_SECRET from the environment
JsonNode secret = tq.auth().generateSecret(user.getEmail());
// secret.path("qrCodeDataUrl") → <img>, secret.path("recoveryCodes") → show once
JsonNode u = tq.auth().getUser(user.getEmail());
boolean linked = u.path("used").asBoolean() && u.hasNonNull("deviceId");from touchque import Touchque
tq = Touchque() # TQ_API_KEY / TQ_API_SECRET from the environment
secret = tq.auth.generate_secret(current_user.email)
# secret['qrCodeDataUrl'] → <img>, secret['recoveryCodes'] → show once
user = tq.auth.get_user(current_user.email)
linked = bool(user.get('used') and user.get('deviceId'))$tq = new \Touchque\Touchque(); // TQ_API_KEY / TQ_API_SECRET from the environment
$secret = $tq->auth->generateSecret($user->email);
// $secret['qrCodeDataUrl'] → <img>, $secret['recoveryCodes'] → show once
$u = $tq->auth->getUser($user->email);
$linked = !empty($u['used']) && !empty($u['deviceId']);tq, err := touchque.New() // TQ_API_KEY / TQ_API_SECRET from the environment
if err != nil {
log.Fatal(err)
}
secret, err := tq.Auth.GenerateSecret(user.Email)
// secret.QRCodeDataURL → <img>, secret.RecoveryCodes → show once
u, err := tq.Auth.GetUser(user.Email)
linked := u.Used && u.DeviceID != ""getUser answers with used: true and a deviceId. The dashboard's 2FA users page lists linked phones too.3. Send the sign-in request after the password
When the password is right, do not open the session yet. Call login.request from your server: Touchque sends a push to the user's phone. Pass the user's IP address and user agent so the phone can show where the sign-in comes from. The request is valid for 30 seconds.
// After the password check passed:
const r = await tq.login.request({
externalUsername: user.email,
type: 'LOGIN',
clientIp: req.ip, // shown on the phone as city, country
userAgent: req.get('user-agent'), // shown as browser and device
});
// r = { requestId, expiresAt, challengeCode?, caseCode }
req.session.pending2fa = { requestId: r.requestId, user: user.email };
res.json({ waiting: true, number: r.challengeCode }); // show the number if there is one// After the password check passed:
var r = await tq.Login.RequestAsync(new LoginRequestOptions
{
ExternalUsername = user.Email,
Type = "LOGIN",
ClientIp = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = Request.Headers.UserAgent.ToString(),
});
HttpContext.Session.SetString("pending2fa", r.RequestId);
// r.ChallengeCode (show it if set), r.CaseCoder = tq.login.request(current_user.email, type: "LOGIN",
client_ip: request.remote_ip, user_agent: request.user_agent)
session[:pending_2fa] = { "request_id" => r["requestId"], "user" => current_user.email }
# r["challengeCode"]: show it if setLogin.RequestResult r = tq.login().request(new LoginRequestOptions(user.getEmail())
.type("LOGIN")
.clientIp(request.getRemoteAddr())
.userAgent(request.getHeader("User-Agent")));
session.setAttribute("pending2fa", r.requestId());
// r.challengeCode() (show it if set), r.caseCode()r = tq.login.request(
external_username=user.email,
type='LOGIN',
client_ip=request.remote_addr,
user_agent=request.headers.get('User-Agent'),
)
session['pending_2fa'] = {'request_id': r['requestId'], 'user': user.email}
# r.get('challengeCode'): show it, the phone asks the user to pick it$r = $tq->login->request(
externalUsername: $user->email,
type: 'LOGIN',
clientIp: $_SERVER['REMOTE_ADDR'],
userAgent: $_SERVER['HTTP_USER_AGENT'] ?? null,
);
$_SESSION['pending_2fa'] = ['request_id' => $r['requestId'], 'user' => $user->email];
// $r['challengeCode']: show it if setr, err := tq.Login.RequestWithOptions(touchque.LoginRequestOptions{
ExternalUsername: user.Email,
Type: "LOGIN",
ClientIP: clientIP(req),
UserAgent: req.UserAgent(),
})
// r.RequestID, r.ChallengeCode (show it if set), r.CaseCodechallengeCode (higher risk, or you asked for requireNumberMatch), show the number on your page: the phone asks the user to pick the same number. That way users only approve the sign-in they started.4. Get the result: webhook or status
Two ways, use either. Webhook (recommended): set your Callback URL under Developers → Webhooks; when the user approves or rejects, Touchque POSTs the result there, signed with your API secret (the Signing secret on that page is the same value). Always verify the x-signature header on the raw body before you trust it. Status check: ask login.status every 2 seconds, for up to 30 seconds. Open the session only for CONFIRMED (status) or SUCCESS (webhook), and only for the requestId and user you stored in step 3.
// Webhook: Developers → Webhooks → Callback URL = https://your.app/webhooks/touchque
app.post('/webhooks/touchque', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = tq.webhook.verify({ rawBody: req.body.toString('utf8'), signature: req.get('x-signature') });
} catch {
return res.sendStatus(403); // not from Touchque, changed, or too old
}
// event.status: 'SUCCESS' | 'REJECTED', event.requestId, event.externalUsername
saveDecision(event.requestId, event.status); // the waiting browser reads it
res.sendStatus(200);
});
// Or ask every 2 seconds while the browser waits:
const s = await tq.login.status(pending.requestId); // PENDING | CONFIRMED | REJECTED | EXPIRED
if (s.status === 'CONFIRMED' && s.externalUsername === pending.user) startSession(pending.user);app.MapPost("/webhooks/touchque", async (HttpRequest request, TouchqueClient tq) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync();
try
{
var e = tq.Webhook.Verify(rawBody, request.Headers["X-Signature"]);
SaveDecision((string?)e["requestId"], (string?)e["status"]); // SUCCESS | REJECTED
return Results.Ok();
}
catch (TouchqueWebhookSignatureException)
{
return Results.StatusCode(403);
}
});
// or poll:
var s = await tq.Login.StatusAsync(pendingRequestId); // PENDING | CONFIRMED | REJECTED | EXPIREDclass TouchqueWebhooksController < ApplicationController
skip_forgery_protection
def create
event = Touchque.client.webhook.verify(request.raw_post, request.headers["X-Signature"])
save_decision(event["requestId"], event["status"]) # SUCCESS | REJECTED
head :ok
rescue Touchque::WebhookSignatureError
head :forbidden
end
end
# or poll:
s = tq.login.status(session[:pending_2fa]["request_id"]) # PENDING | CONFIRMED | REJECTED | EXPIRED@PostMapping("/webhooks/touchque")
ResponseEntity<Void> webhook(@RequestBody String rawBody, @RequestHeader("X-Signature") String signature) {
try {
ObjectNode event = tq.webhook().verify(rawBody, signature);
saveDecision(event.path("requestId").asText(), event.path("status").asText()); // SUCCESS | REJECTED
return ResponseEntity.ok().build();
} catch (TouchqueWebhookSignatureException e) {
return ResponseEntity.status(403).build();
}
}
// or poll:
Login.Status s = tq.login().status(pendingRequestId); // PENDING | CONFIRMED | REJECTED | EXPIRED@app.post('/webhooks/touchque')
def touchque_webhook():
try:
event = tq.webhook.verify(raw_body=request.get_data(as_text=True),
signature=request.headers.get('x-signature'))
except TouchqueWebhookSignatureException:
return '', 403
save_decision(event['requestId'], event['status']) # SUCCESS | REJECTED
return '', 200
# or poll:
s = tq.login.status(pending['request_id']) # PENDING | CONFIRMED | REJECTED | EXPIRED
if s['status'] == 'CONFIRMED' and s['externalUsername'] == pending['user']:
start_session(pending['user'])try {
$event = $tq->webhook->verify(file_get_contents('php://input'), $_SERVER['HTTP_X_SIGNATURE'] ?? null);
} catch (\Touchque\Exceptions\TouchqueWebhookSignatureException $e) {
http_response_code(403);
exit;
}
saveDecision($event['requestId'], $event['status']); // SUCCESS | REJECTED
// or poll:
$s = $tq->login->status($pending['request_id']); // PENDING | CONFIRMED | REJECTED | EXPIREDbody, _ := io.ReadAll(r.Body)
event, err := tq.Webhook.Verify(string(body), r.Header.Get("x-signature"))
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
saveDecision(event["requestId"].(string), event["status"].(string)) // SUCCESS | REJECTED
// or poll:
s, err := tq.Login.Status(pending.RequestID) // s.Status: PENDING | CONFIRMED | REJECTED | EXPIREDjti or requestId.5. Handle the other answers
Every answer carries a caseCode such as K7P-2QX. When a request is refused, show it to the user; your team looks it up under Logs → Look up a case in the dashboard and sees why.
| Answer | What to do |
|---|---|
200 with challengeCode | Number matching: show the number on your page. The phone asks the user to pick it. |
404 | The user has no linked phone yet. Send them to step 2, or accept a recovery code. |
403 blocked | The risk engine refused the request. Show the caseCode: "Tell your support team: K7P-2QX". |
403 device_keys_lost | The phone lost its keys (reinstalled app, restored phone). The user links the phone again. |
423 frozen | Too many rejected requests. Check who the user is, then reset the secret (reset also unfreezes). |
429 rate_limited | More than 5 pushes in 10 minutes for this user. Wait retryAfter seconds. |
requiresPasskey: true | Your policy wants a passkey for this action, so no push was sent. See Passkeys. |
REJECTED | The user tapped Reject. rejectReason says whether they reported it ("This wasn't me"). |
EXPIRED | 30 seconds passed without an answer. Offer to send a new request. |
const { TouchqueAPIError } = require('@touchque/node');
try {
await tq.login.request({ externalUsername: user.email, type: 'LOGIN', clientIp: req.ip });
} catch (err) {
if (!(err instanceof TouchqueAPIError)) throw err;
if (err.status === 404) return res.json({ enroll: true }); // no linked phone
if (err.caseCode) return res.status(err.status).json({ // blocked, frozen, rate_limited
message: `Tell your support team: ${err.caseCode}`,
});
throw err;
}try
{
await tq.Login.RequestAsync(new LoginRequestOptions { ExternalUsername = user.Email, ClientIp = ip });
}
catch (TouchqueApiException e) when (e.Status == 404)
{
return Results.Json(new { enroll = true }); // no linked phone
}
catch (TouchqueApiException e) when (e.CaseCode is not null) // blocked, frozen, rate_limited
{
return Results.Json(new { message = $"Tell your support team: {e.CaseCode}" }, statusCode: e.Status);
}begin
tq.login.request(current_user.email, type: "LOGIN", client_ip: request.remote_ip)
rescue Touchque::APIError => e
return render json: { enroll: true } if e.status == 404 # no linked phone
raise unless e.case_code # blocked, frozen, rate_limited
render json: { message: "Tell your support team: #{e.case_code}" }, status: e.status
endtry {
tq.login().request(new LoginRequestOptions(user.getEmail()).clientIp(request.getRemoteAddr()));
} catch (TouchqueApiException e) {
if (e.status() == 404) return ResponseEntity.ok(Map.of("enroll", true)); // no linked phone
if (e.caseCode() == null) throw e; // blocked, frozen, rate_limited
return ResponseEntity.status(e.status()).body(Map.of("message", "Tell your support team: " + e.caseCode()));
}_, err := tq.Login.RequestWithOptions(touchque.LoginRequestOptions{ExternalUsername: user.Email, Type: "LOGIN", ClientIP: ip})
var apiErr *touchque.APIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode == http.StatusNotFound {
json.NewEncoder(w).Encode(map[string]bool{"enroll": true}) // no linked phone
return
}
if apiErr.CaseCode != "" { // blocked, frozen, rate_limited
w.WriteHeader(apiErr.StatusCode)
json.NewEncoder(w).Encode(map[string]string{"message": "Tell your support team: " + apiErr.CaseCode})
return
}
}Moving users off Google Authenticator
- Google Authenticator secrets cannot be imported into Touchque: each user links their phone once with the QR from step 2.
- Keep both methods during the move: a push for users with a linked phone, the old code for the rest. Offer "Approve with Touchque" at their next sign-in.
- Once a user's phone is linked (used: true), delete their old TOTP secret, so no phishable code is left.
- For users whose phone may be offline, turn on offline sign under App user policy in the dashboard.
Shortcuts: one-line middleware and ready-made screens
The steps above are the raw calls. The SDKs also wrap them: requireTouchque('LOGIN') (Express), @require_touchque (Flask, Django, FastAPI), ->middleware('touchque:LOGIN') (Laravel) and touchque.Require (Go) answer 202 until the phone approves. On the page, @touchque/web and @touchque/react give you the enrolment QR, the waiting screen with the number and the passkey step. Switch the sidebar to SDKs for these guides.
Before going live
- API key and secret on the server only; requests over HTTPS.
- The webhook signature is verified; a session opens only for the requestId you started.
- Recovery codes are shown at enrolment and you have a path for a lost phone (see Troubleshooting).
- Refused requests show the caseCode to the user.
- Linking, approving, rejecting and expiry were tried with a real phone.