Your values Fill in your own to see them in every example
On the app’s page in the hub, under Credentials.
That doesn’t look right yet. For example: q3Jx8vN2mZ0bT5sLwK7yHc4dR1fGaE9uPiO6nVjXkMs
Scheme and host, no path: where your app runs.
That doesn’t look right yet. For example: https://app.example.com
Client secret stays a marked placeholder here, never filled in: the hub gives you secrets, and only your server should hold them.
Saved in this browser only, for every page of these docs. Nothing you type leaves this page.
A passkey proves who signed in. It doesn’t stop malware already on a signed-in laptop from using that live session in your app. Countersign asks the person to confirm one sensitive action (a change of salary account, a role grant, a deletion, a refund) at the moment it happens. They see exactly what they are agreeing to, and your server receives a signed proof bound to that one action, which it checks before it acts.
Your organization’s admin decides, per app, how the person countersigns:
| Setting | What the person does |
|---|---|
| Passkey | A Trustaige window opens over your page. They read the action and confirm with their passkey. |
| Phone | They approve in the Trustaige Identity app on their phone. Not yet available to organizations. |
Your app makes the same request either way and never chooses the method.
- Person to Your page: saves a change
- Your page to Your server: the change
- Your server to Trustaige: POST /countersign/bc-authorize
- Trustaige to Your server: auth_req_id, frame_url, txn_hash
- Your page to Trustaige: countersign.js opens frame_url
- Person and Trustaige: reads the action, passkey
- Your server to Trustaige: POST /countersign/token
- Trustaige to Your server: verification_proof
- Your server to Person: checks the proof, applies the change
Quickstart
-
Turn it on for your app. In the hub, open Apps, choose your app and open its Countersign tab. Pick Passkey. Countersign is for applications your organization runs itself, signing people in with OpenID Connect and authenticating as themselves (a client secret or a key), so the tab appears only for those. Then press Try Countersign to see the whole experience on yourself; nothing changes in your app.
-
Ask Trustaige when a sensitive change comes in. Describe the action (see Describe the action) and post it to
bc-authorizefrom your server, authenticated as your app, exactly as at the token endpoint. Storeauth_req_idandtxn_hashwith the pending change.const details = [{ type: 'acme.payroll.bank_account.change', summary: `Change where ${employee.name} is paid`, fields: [ { name: 'account_number', title: 'New account', value: `•••• ${account.slice(-4)}`, format: 'emphasis' }, { name: 'bank', title: 'Bank', value: bank, format: 'bold' }, ], }]; const res = await fetch('https://id.trustaige.com/countersign/bc-authorize', { method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(`<your-client-id>:${process.env.CLIENT_SECRET}`).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ login_hint: session.sub, authorization_details: JSON.stringify(details), frame_origin: '<your-app-url>', }), }); const started = await res.json(); if (!res.ok) throw new Error(started.error_description || started.error); await pending.save({ id: change.id, authReqId: started.auth_req_id, txnHash: started.txn_hash, details, sub: session.sub }); return { method: started.method, frameUrl: started.frame_url, code: started.binding_code };form := url.Values{ "login_hint": {session.Sub}, "authorization_details": {string(details)}, "frame_origin": {"<your-app-url>"}, } req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://id.trustaige.com/countersign/bc-authorize", strings.NewReader(form.Encode())) req.SetBasicAuth("<your-client-id>", os.Getenv("CLIENT_SECRET")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() var started struct { AuthReqID string `json:"auth_req_id"` Method string `json:"method"` FrameURL string `json:"frame_url"` BindingCode string `json:"binding_code"` TxnHash string `json:"txn_hash"` Error string `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&started); err != nil || resp.StatusCode != http.StatusOK { return fmt.Errorf("countersign refused: %s", started.Error) } return pending.Save(ctx, change.ID, started.AuthReqID, started.TxnHash, details, session.Sub)started = requests.post( "https://id.trustaige.com/countersign/bc-authorize", auth=("<your-client-id>", os.environ["CLIENT_SECRET"]), data={ "login_hint": session["sub"], "authorization_details": json.dumps(details), "frame_origin": "<your-app-url>", }, timeout=10, ) body = started.json() if started.status_code != 200: raise RuntimeError(body.get("error_description", body["error"])) pending.save(change.id, auth_req_id=body["auth_req_id"], txn_hash=body["txn_hash"], details=details, sub=session["sub"])curl https://id.trustaige.com/countersign/bc-authorize \ -u '<your-client-id>:<your-client-secret>' \ --data-urlencode 'login_hint=<sub>' \ --data-urlencode 'authorization_details=[{"type":"acme.payroll.bank_account.change","summary":"Change where Kemi Adeyemi is paid","fields":[{"name":"account_number","title":"New account","value":"•••• 4321","format":"emphasis"}]}]' \ --data-urlencode 'frame_origin=<your-app-url>' -
Open the Trustaige window on your page. Load the script once and hand it the
frame_url. It covers your page with the pass and resolves when the person has answered. Disable your own submit button until then, so the change can’t be sent twice.<script src="https://id.trustaige.com/countersign.js" defer></script> <script> async function save(change) { saveButton.disabled = true; const started = await post('/changes', change); const outcome = await TrustaigeCountersign.open(started.frameUrl); // approved, declined, ended or unavailable: your server decides await post(`/changes/${started.id}/collect`); saveButton.disabled = false; } </script>The outcome only tells your page to look. Never apply a change because the page says
approved: the proof travels server to server. -
Collect the proof and check it before you act. Poll the token endpoint with the
auth_req_id, no faster thanintervalseconds. When the answer is a proof, run every check in Check the proof, then apply the change.const res = await fetch('https://id.trustaige.com/countersign/token', { method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(`<your-client-id>:${process.env.CLIENT_SECRET}`).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ auth_req_id: change.authReqId }), }); const answer = await res.json(); if (answer.error === 'authorization_pending' || answer.error === 'slow_down') return 'waiting'; if (!res.ok) return refuse(change, answer.error); await checkProof(answer.verification_proof, change); await apply(change);curl https://id.trustaige.com/countersign/token \ -u '<your-client-id>:<your-client-secret>' \ -d 'auth_req_id=<auth_req_id>'
The request
POST https://id.trustaige.com/countersign/bc-authorize, form-encoded, with
your app’s client authentication as it is registered: client_secret_basic,
client_secret_post or private_key_jwt. A private_key_jwt assertion is
addressed (aud) to https://id.trustaige.com or its token endpoint,
exactly as for the token endpoint. A public client can’t ask.
| Parameter | |
|---|---|
login_hint | Required. The sub your app received for the person when they signed in. |
authorization_details | Required. The action, as a JSON array: see Describe the action. |
binding_message | Optional, up to 140 characters. The headline when no entry has a summary. |
frame_origin | Required for Passkey. The origin of the page that opens the window, which must be the origin of one of your app’s redirect URIs. |
nonce | Optional. Echoed in the proof. |
A 200 answer:
| Member | |
|---|---|
auth_req_id | The request. Collect its proof with it, and check it against the proof’s jti. |
method | same_device_passkey or phone_approval, as the admin set it. |
frame_url | Passkey only. Hand it to TrustaigeCountersign.open. |
binding_code | Phone only. Show it beside your own “Approve on your phone”; the person picks the same code on their phone. |
txn_hash | The value the proof’s txn_hash will carry. Store it with the pending change. |
expires_in, interval | Seconds until the request ends (5 minutes for Passkey, 3 for Phone), and between polls (2). |
An app set to Phone gets no frame_url: there is no Trustaige window, and
your page shows its own wait with the binding_code. Handle both methods, so
your app keeps working when the admin changes the setting.
The answer
POST https://id.trustaige.com/countersign/token with auth_req_id and the
same client authentication. A 200 answer is
{"verification_proof": "<JWT>", "token_type": "verification+jwt"}. A proof
is collected once, and is valid for two minutes from the person’s answer, so
keep polling while the request is open.
error | Means | Do |
|---|---|---|
authorization_pending | The person hasn’t answered yet. | Poll again after interval. |
slow_down | You polled faster than interval. | Wait longer between polls. |
access_denied | The person declined, or reported it as not theirs. | Don’t apply the change. |
expired_token | Nobody answered before the request ended. | Ask again if the person still wants it. |
invalid_grant | The proof was already collected, or the request is another app’s. | Don’t apply the change. |
Troubleshooting
“unauthorized_client”
Countersign is off for the app, or the app isn’t one your organization runs itself: an app made from a catalog template can’t use it. An admin turns it on in the app’s Countersign tab.
“invalid_request”: frame_origin
frame_origin must be the scheme and host (and port) of one of the app’s
redirect URIs, such as <your-app-url>. Add the page’s origin as a redirect
URI, or send the one you already have.
“unknown_user_id”
login_hint must be the sub from the person’s ID token for this app, and
the person must still have access to the organization.
“invalid_authorization_details”
The action isn’t in the shape Countersign shows. The error_description says
which entry and field, for example that a field needs a title. Try it in
the hub shows the same reason for a request you paste in.
“phone_approval_unavailable”
The app is set to Phone, and the person hasn’t set up Trustaige Identity for approvals, or Phone isn’t available to the organization. Trustaige never switches to the passkey instead: tell the person, or ask the admin to set the app to Passkey.
The window opens, but the passkey prompt doesn't
Your page sets a Permissions-Policy that blocks
publickey-credentials-get for frames. Allow it for
https://id.trustaige.com.
Next
- Describe the action: the fields the person reads, and how long an action should be.
- Check the proof: every check your server makes before it acts.