Touchque Login
Sign-up and sign-in pages for your own customers, on your brand and your domain: passkeys first, the Touchque app or email/SMS codes as the second step, social and enterprise connections, organizations for business customers. The pages carry your logo and colours and always end with a small “Secured by Touchque” line. It is standard OpenID Connect, so any OIDC library works; the SDKs below save you the details.
- Dashboard → Touchque Login → Setup: choose your address (
acme.login.touchque.com). - Apps → add your app with its callback URL. Its page shows the code with your values.
- In your project:
npm init @touchque -- --login(Next.js, Express, ASP.NET Core, Rails, Spring Boot; Python, PHP, Go and Sinatra files too).--loginis in @touchque/create 2.0.2 and later. - Fill
.envand open/auth/login.
| Path | What |
|---|---|
/.well-known/openid-configuration | Discovery |
/authorize | screen_hint=signup, login_hint, ui_locales, connection, organization, invitation, prompt, max_age, acr_values |
/oauth/token | authorization_code (PKCE), refresh_token (rotating), client_credentials, token exchange (RFC 8693), CIBA |
/bc-authorize | CIBA (poll mode): an app asks a customer to approve on their phone |
/userinfo · /oauth/revoke · /oauth/introspect | OpenID Connect and OAuth 2.0 |
/oidc/logout | id_token_hint, post_logout_redirect_uri |
/account | Your customers' own account page |
/me/v1 | My Account API (audience {issuer}/me/, first-party apps) |
state on every sign-in, exact callback URLs, the issuer on the response (RFC 9207), ID tokens checked against the JWKS, refresh tokens that rotate (a reused one ends the whole family), passwords hashed with Argon2id and checked against known breaches.| Protection | What it does |
|---|---|
| Wrong passwords | 10 in 15 minutes pause password sign-in for that account; the owner gets an unlock link. |
| Suspicious addresses | Many failures across accounts from one address: the bot challenge, or an hour's wait. |
| Bot challenge | Turnstile, hCaptcha or reCAPTCHA v2, checked on the server with the hostname. |
| Breached passwords | Found at sign-in: a new password after the second step, or an email. |
| Adaptive second step | Asked only when a sign-in looks risky (new browser, suspicious address, failed attempts, the risk engine). |
Your Actions run on sign-in too: identity.post_login can refuse or ask for a second step, identity.pre_user_registration can refuse a sign-up. Customers manage their own profile, passkeys, devices, app access and data at /account, or in your app through the My Account API and <UserProfile>.
Turn it on for a confidential app (Touchque Login → Setup → Apps → the app → Acting for your customers; your phone approves). With token exchange (RFC 8693) the app trades a signed-in customer's access token for one to call your API: sub is the customer, act.sub the app, the scopes are what the API grants the app (and the customer's roles allow), and it ends with the customer's token; a third app in a chain is refused. With CIBA the app asks the customer on their phone: Touchque draws the authorization_details as rows, shows the app's name first and its message as unverified, asks for Face ID and the number the app shows, and the tokens carry exactly what was approved, once. An approval given during a phone call is not enough.
Token vault. When a connection keeps tokens (Touchque Login → Setup → Connections → the connection → Keep tokens for apps working for people; your phone approves), an app acting for a customer gets that customer's own Google, Microsoft or GitHub access token by token exchange with requested_token_type=urn:touchque:params:oauth:token-type:connection-access-token and connection. Touchque keeps the tokens sealed, refreshes them and never hands out the refresh token; scope may only narrow what the provider granted. The customer's token must be the app's own, unless the connection lists the app. Customers stop it on /account (or stopAppAccess), you with DELETE /identity/users/{id}/connected-accounts/{accountId}; unlinking the account or deleting the customer deletes the tokens.
// npm init @touchque -- --login (writes these files)
// lib/touchque.ts
import { createTouchqueLogin } from '@touchque/node';
export const touchque = createTouchqueLogin({
domain: ({}).TOUCHQUE_LOGIN_DOMAIN!, // acme.login.touchque.com
clientId: ({}).TOUCHQUE_LOGIN_CLIENT_ID!,
clientSecret: ({}).TOUCHQUE_LOGIN_CLIENT_SECRET,
secret: ({}).TOUCHQUE_SESSION_SECRET!, // 32+ random characters
appUrl: ({}).APP_URL!,
});
// app/auth/[action]/route.ts
export const { GET } = touchque.handlers;
// middleware.ts (Edge)
import { touchqueMiddleware } from '@touchque/node/next/middleware';
export default touchqueMiddleware({ secret: ({}).TOUCHQUE_SESSION_SECRET!, protect: ['/dashboard'] });const { HostedLogin, hostedLoginRoutes, requireSignIn } = require('@touchque/node');
const login = new HostedLogin({ domain: 'acme.login.touchque.com', clientId, clientSecret,
redirectUri: 'https://app.acme.com/callback' });
app.use(hostedLoginRoutes({ login, secret: ({}).SESSION_SECRET })); // /login, /callback, /logout
app.get('/account', requireSignIn(), (req, res) => res.json(req.touchqueSession.user));import { HostedLoginProvider, TouchqueLogin, UserProfile } from '@touchque/react';
<HostedLoginProvider domain="acme.login.touchque.com" clientId={id} redirectUri={`${location.origin}/callback`}>
<header><TouchqueLogin /></header> {/* Sign in · Create account · user menu */}
<UserProfile /> {/* profile, passkeys, devices, apps */}
</HostedLoginProvider>from touchque import HostedLogin
login = HostedLogin(domain="acme.login.touchque.com", client_id=CLIENT_ID, client_secret=CLIENT_SECRET,
redirect_uri="https://app.acme.com/callback")
req = login.authorization_url() # keep state, nonce, code_verifier; redirect to req.url
tokens = login.handle_callback(request.url, **session.pop("tq"))use Touchque\HostedLogin\HostedLogin;
$login = new HostedLogin(['domain' => 'acme.login.touchque.com', 'clientId' => $id, 'clientSecret' => $secret,
'redirectUri' => 'https://app.acme.com/callback']);
$req = $login->authorizationUrl();
$tokens = $login->handleCallback($_GET, $state, $nonce, $codeVerifier);login, _ := touchque.NewHostedLogin(touchque.HostedLoginConfig{
Domain: "acme.login.touchque.com", ClientID: id, ClientSecret: secret, RedirectURI: "https://app.acme.com/callback",
})
req, _ := login.AuthorizationURL(touchque.AuthorizationOptions{})
tokens, err := login.HandleCallback(ctx, r.URL.Query(), touchque.CallbackChecks{State: s, Nonce: n, CodeVerifier: v})// ASP.NET Core: cookie session + /login, /callback, /logout
builder.Services.AddTouchqueLogin(o =>
{
o.Domain = "acme.login.touchque.com"; o.ClientId = id; o.ClientSecret = secret;
o.Scope = "openid profile email offline_access";
});
app.UseAuthentication(); app.UseAuthorization();
app.MapTouchqueLogin();
app.MapGet("/account", (ClaimsPrincipal user) => user.FindFirst("email")?.Value).RequireAuthorization();
// Your API: AddAuthentication().AddTouchqueBearer(o => { o.Domain = "acme.login.touchque.com"; o.Audience = "https://api.acme.com"; })# application.properties (Spring Boot starter + spring-boot-starter-oauth2-client)
touchque.login.domain=acme.login.touchque.com
touchque.login.client-id=…
touchque.login.client-secret=…
touchque.login.scope=openid,profile,email,offline_access
# → every page signs in with Touchque Login (PKCE): /oauth2/authorization/touchque, sign-out POST /logout
# Your own SecurityFilterChain: touchqueLogin.configure(http)
# Your API (resource server): a JwtDecoder for at+jwt access tokens
touchque.login.audience=https://api.acme.com# config/application.rb
config.touchque.login = { domain: "acme.login.touchque.com", client_id: ENV["CLIENT_ID"], client_secret: ENV["CLIENT_SECRET"] }
# config/routes.rb
Touchque::Rails.login_routes(self) # /login, /callback, /logout
class AccountController < ApplicationController
include Touchque::Rails::HostedLogin
before_action :require_touchque_sign_in
def show = render(json: touchque_session["user"])
endconst { users } = await tq.identity.users.list({ q: 'ayse' });
await tq.identity.users.block(users[0].id, { reason: 'chargeback' });
const job = await tq.identity.imports.start({ users: [{ email: 'old@acme.com', passwordHash: '$2b$10$…' }] });
await tq.identity.setDelivery('email', { provider: 'postmark', from: 'login@acme.com', serverToken });
await tq.identity.setAttackProtection({ bot: { provider: 'turnstile', siteKey, secret, mode: 'risky' } });
const data = await tq.identity.users.export(userId); // KVKK / GDPRusers = tq.identity.users.list(q="ayse")["users"]
tq.identity.users.block(users[0]["id"], reason="chargeback")
data = tq.identity.users.export(user_id) # KVKK / GDPR$users = $tq->identity->users->list(['q' => 'ayse'])['users'];
$tq->identity->users->block($users[0]['id'], 'chargeback');
$data = $tq->identity->users->export($userId); // KVKK / GDPRpage, err := tq.Identity.Users.List(map[string]string{"q": "ayse"})
users, _ := page["users"].([]interface{})
first, _ := users[0].(map[string]interface{})
_, err = tq.Identity.Users.Block(first["id"].(string), "chargeback")
data, err := tq.Identity.Users.Export(userID) // KVKK / GDPRvar page = await tq.Identity.Users.ListAsync(new { q = "ayse" });
var userId = (string)page["users"]![0]!["id"]!;
await tq.Identity.Users.BlockAsync(userId, "chargeback");
var data = await tq.Identity.Users.ExportAsync(userId); // KVKK / GDPRJsonNode users = tq.identity().users().list(Map.of("q", "ayse")).get("users");
tq.identity().users().block(users.get(0).get("id").asText(), "chargeback");
JsonNode data = tq.identity().users().export(userId); // KVKK / GDPRusers = tq.identity.users.list(q: "ayse")["users"]
tq.identity.users.block(users[0]["id"], reason: "chargeback")
data = tq.identity.users.export(user_id) # KVKK / GDPRGET /identity/users?q=ayse
POST /identity/users/{id}/block
POST /identity/users-imports
PUT /identity/delivery/email
PUT /identity/attack-protection
GET /identity/users/{id}/export
DELETE /identity/users/{id}// An AI agent of yours (a confidential app with these grants on, Touchque Login → Setup → Apps → the app)
const forAda = await login.exchangeToken(adasAccessToken, { audience: 'https://api.acme.com' }); // sub = Ada, act.sub = the app
const ask = await login.askUser({ loginHint: 'ada@acme.com', scope: 'openid payments:create', audience: 'https://api.acme.com',
bindingMessage: 'Pay invoice 42', authorizationDetails: [payment] });
reply(`Type ${ask.bindingCode} in the Touchque app`);
const approved = await login.waitForUser(ask); // approved.authorizationDetails
// Token vault: Ada's own Google token (Touchque Login → Setup → Connections → Google → Keep tokens…)
const google = await login.getConnectionToken(adasAccessToken, { connection: 'google' });
await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { headers: { Authorization: `Bearer ${google.accessToken}` } });for_ada = login.exchange_token(adas_access_token, audience="https://api.acme.com")
ask = login.ask_user("ada@acme.com", "openid payments:create", audience="https://api.acme.com", binding_message="Pay invoice 42")
approved = login.wait_for_user(ask)
google = login.get_connection_token(adas_access_token, "google") # token vault$forAda = $login->exchangeToken($adasAccessToken, 'https://api.acme.com');
$ask = $login->askUser('ada@acme.com', 'openid payments:create', ['audience' => 'https://api.acme.com', 'bindingMessage' => 'Pay invoice 42']);
$approved = $login->waitForUser($ask);
$google = $login->getConnectionToken($adasAccessToken, 'google'); // token vaultforAda, _ := login.ExchangeToken(ctx, adasAccessToken, "https://api.acme.com", "")
ask, _ := login.AskUser(ctx, touchque.AskUserParams{LoginHint: "ada@acme.com", Scope: "openid payments:create", Audience: "https://api.acme.com", BindingMessage: "Pay invoice 42"})
approved, err := login.WaitForUser(ctx, ask)
google, err := login.GetConnectionToken(ctx, adasAccessToken, "google", "") // token vaultvar forAda = await login.ExchangeTokenAsync(adasAccessToken, "https://api.acme.com");
var ask = await login.AskUserAsync("ada@acme.com", "openid payments:create",
new AskUserOptions { Audience = "https://api.acme.com", BindingMessage = "Pay invoice 42" });
var approved = await login.WaitForUserAsync(ask);
var google = await login.GetConnectionTokenAsync(adasAccessToken, "google"); // token vaultvar forAda = login.exchangeToken(adasAccessToken, "https://api.acme.com", null);
var ask = login.askUser("ada@acme.com", "openid payments:create",
new HostedLogin.AskUserOptions().audience("https://api.acme.com").bindingMessage("Pay invoice 42"));
var approved = login.waitForUser(ask);
var google = login.getConnectionToken(adasAccessToken, "google", null); // token vaultfor_ada = login.exchange_token(adas_access_token, "https://api.acme.com")
ask = login.ask_user("ada@acme.com", "openid payments:create", audience: "https://api.acme.com", binding_message: "Pay invoice 42")
approved = login.wait_for_user(ask)
google = login.get_connection_token(adas_access_token, "google") # token vaultPOST /bc-authorize (Basic client_id:client_secret)
login_hint=ada@acme.com&scope=openid payments:create&audience=https://api.acme.com
&binding_message=Pay invoice 42&authorization_details=[…]
# → { "auth_req_id": "…", "expires_in": 300, "interval": 5, "binding_code": "47" }
POST /oauth/token grant_type=urn:openid:params:grant-type:ciba&auth_req_id=…
POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<customer's access token>&subject_token_type=urn:ietf:params:oauth:token-type:access_token&audience=…
# Token vault: the provider's own access token instead of audience
&requested_token_type=urn:touchque:params:oauth:token-type:connection-access-token&connection=google