Skip to content
Trustaige Docs

Countersign

Have the signed-in person confirm one exact action before your app carries it out, and receive a signed proof bound to that action alone.

  • Every plan
  • Needs an in-house OpenID Connect app with a client secret or key
  • About 30 minutes
View as Markdown

Get started

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:

SettingWhat the person does
PasskeyA Trustaige window opens over your page. They read the action and confirm with their passkey.
PhoneThey 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.

  1. Person to Your page: saves a change
  2. Your page to Your server: the change
  3. Your server to Trustaige: POST /countersign/bc-authorize
  4. Trustaige to Your server: auth_req_id, frame_url, txn_hash
  5. Your page to Trustaige: countersign.js opens frame_url
  6. Person and Trustaige: reads the action, passkey
  7. Your server to Trustaige: POST /countersign/token
  8. Trustaige to Your server: verification_proof
  9. Your server to Person: checks the proof, applies the change

Quickstart

  1. 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.

  2. Ask Trustaige when a sensitive change comes in. Describe the action (see Describe the action) and post it to bc-authorize from your server, authenticated as your app, exactly as at the token endpoint. Store auth_req_id and txn_hash with 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>'
  3. 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.

  4. Collect the proof and check it before you act. Poll the token endpoint with the auth_req_id, no faster than interval seconds. 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_hintRequired. The sub your app received for the person when they signed in.
authorization_detailsRequired. The action, as a JSON array: see Describe the action.
binding_messageOptional, up to 140 characters. The headline when no entry has a summary.
frame_originRequired for Passkey. The origin of the page that opens the window, which must be the origin of one of your app’s redirect URIs.
nonceOptional. Echoed in the proof.

A 200 answer:

Member
auth_req_idThe request. Collect its proof with it, and check it against the proof’s jti.
methodsame_device_passkey or phone_approval, as the admin set it.
frame_urlPasskey only. Hand it to TrustaigeCountersign.open.
binding_codePhone only. Show it beside your own “Approve on your phone”; the person picks the same code on their phone.
txn_hashThe value the proof’s txn_hash will carry. Store it with the pending change.
expires_in, intervalSeconds 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.

errorMeansDo
authorization_pendingThe person hasn’t answered yet.Poll again after interval.
slow_downYou polled faster than interval.Wait longer between polls.
access_deniedThe person declined, or reported it as not theirs.Don’t apply the change.
expired_tokenNobody answered before the request ended.Ask again if the person still wants it.
invalid_grantThe 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