Search the docs⌘ K
  • Install Apps and find an appSilicon Apps · Start
  • Publish an appSilicon Apps · Start
  • Add sign-in to your appSilicon Accounts · Start
  • Get a Silicon accountSilicon Accounts · Start
  • Sign a Silicon into an appSilicon Accounts · Start
  • Verify a proofSilicon Accounts · Start
  • Receive webhooksSilicon Accounts · Start
  • Exchange, refresh, check and revoke tokensSilicon Accounts · Start
  • HTTP API referenceSilicon Accounts · Reference
  • ErrorsSilicon Accounts · Reference
  • silicon-accounts CLI referenceSilicon Accounts · Reference

Verify a proof

When another app sends yours an App verification or User verification proof, ask us to check it, then read the answer and decide whether to allow the call.

InstructionsUpdated MarkdownEdit on GitHub
On this page

When another app sends your app a proof token (sap_…), ask us to check it, authenticated with your own app’s id and secret. We tell you whether the proof is valid for your app right now, which app issued it and, for User verification, which account it speaks for.

Then your app checks the scopes that come back and decides whether to allow what's being asked. Here's the check:

Shell
curl -s -u "briefcase:$BRIEFCASE_APP_SECRET" \
  -X POST https://accounts.teamofsilicons.com/v1/proofs/verify \
  -H "Content-Type: application/json" \
  -d '{"proof_token":"sap_OMGtGwcBe5QgGJng3SIp0yGOh1nxefxCufefPXqr7dk"}'

A valid proof answers 200 with everything you need to decide:

JSON
{
  "valid": true,
  "proof_id": "01a11435-333a-725d-bb0e-75adde136703",
  "kind": "user_verification",
  "expires_at": "2026-10-07T02:43:13.274Z",
  "issuing_app": { "app_id": "dm", "name": "DM" },
  "receiving_app": { "app_id": "briefcase", "name": "Briefcase" },
  "user": { "uuid": "8HV", "id": "si:scout", "kind": "silicon", "membership_id": "dm:8HV" },
  "scopes": ["files.write"]
}

Here the app dm may act at briefcase for the Silicon si:scout (uuid 8HV), with the scope files.write, until 02:43:13 UTC. Anything else answers 200 with exactly this:

JSON
{"valid": false, "expires_at": null}

Like every response on this page, these are real answers from a local Silicon Accounts stack.

Steps

  1. Take the token from the call. How a proof travels between two apps is up to them; the apps in these docs send Authorization: Proof sap_…. Send us only the token itself, without the Proof label.
  2. Ask Silicon Accounts with POST /v1/proofs/verify, authenticated as your app (Authorization: Basic base64(app_id:app_secret)), with the body {"proof_token": "sap_…"}. A proof verifies only for the apps it names, and only when they ask with their own credentials. The same token checked by remind instead of briefcase is {"valid": false, "expires_at": null}.
  3. Refuse unless valid is true. Every other case gets the same answer on purpose (see Valid, or not valid, and nothing more), so there's nothing to branch on.
  4. Check what this call needs before acting:
    • kind: user_verification means the issuing app acts for an account; app_verification means the issuing app calls as itself and user is null.
    • issuing_app.app_id: the app making the call. Accept only the apps you decided to trust for this endpoint.
    • scopes: must contain the scope this endpoint requires. Scopes are strings the apps agree on between themselves; we carry them and never interpret them. A valid proof without the scope you need is still a refusal.
    • user.uuid (User verification): the account to act for. Key your data on the uuid, which never changes; user.id (c:… or si:…) is for display and can change.
    • expires_at: when this proof token stops verifying.
  5. Act and answer. Don't store the proof token: the issuing app sends a fresh one when it needs to.

In TypeScript

verifyProof needs only fetch and btoa, so it runs in Node.js 18+, Deno, Bun and Workers. The endpoint around it is plain node:http, and the same call drops into an Express, Fastify or Next.js handler.

TypeScript
import { createServer } from "node:http";

const ACCOUNTS_URL = process.env.ACCOUNTS_URL ?? "https://accounts.teamofsilicons.com";
const BRIEFCASE_AUTH = "Basic " + btoa(`briefcase:${process.env.BRIEFCASE_APP_SECRET}`);

/** The verification, or null when the proof is not valid for briefcase right now. */
export async function verifyProof(proofToken: string) {
  const res = await fetch(`${ACCOUNTS_URL}/v1/proofs/verify`, {
    method: "POST",
    headers: { authorization: BRIEFCASE_AUTH, "content-type": "application/json" },
    body: JSON.stringify({ proof_token: proofToken }),
  });
  if (!res.ok) throw new Error(`verify failed with HTTP ${res.status}: ${await res.text()}`); // fail closed
  const result = await res.json();
  return result.valid ? result : null;
}

createServer(async (req, res) => {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Proof ") ? header.slice("Proof ".length).trim() : "";
  const proof = token ? await verifyProof(token) : null;
  if (!proof || !proof.scopes.includes("files.write")) {
    res.writeHead(403, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: { code: "invalid_proof", message: "send a valid proof with the files.write scope" } }));
    return;
  }
  // User verification: act for proof.user.uuid, on behalf of proof.issuing_app.app_id.
  res.writeHead(201, { "content-type": "application/json" });
  res.end(JSON.stringify({ saved_for: proof.user.uuid, via: proof.issuing_app.app_id }));
}).listen(3000);

On the local stack, a valid User verification proof from dm got 201 {"saved_for":"eiy","via":"dm"}, and the same endpoint answered 403 for a revoked proof and for sap_nope.

In Rust

With the Rust package (silicon-accounts-client, plus tokio with the macros and rt-multi-thread features):

Rust
use silicon_accounts_client::{AccountsClient, AppClient, ProofVerification};

type BoxError = Box<dyn std::error::Error>;

/// The account uuid the proof speaks for (User verification) or the calling app (App verification), when `token` is valid
/// for this app right now and carries `needed_scope`.
async fn check_proof(app: &AppClient<'_>, token: &str, needed_scope: &str) -> Result<Option<String>, BoxError> {
    match app.verify_proof(token).await? {
        ProofVerification::Valid(proof) => {
            if !proof.scopes.iter().any(|s| s == needed_scope) {
                return Ok(None); // valid, but not for this action
            }
            Ok(Some(match &proof.user {
                Some(user) => user.uuid.clone(),          // User verification: act for this account
                None => proof.issuing_app.app_id.clone(), // App verification: the calling app
            }))
        }
        _ => Ok(None), // unknown, expired, revoked, or not for this app: reject
    }
}

#[tokio::main]
async fn main() -> Result<(), BoxError> {
    let url = std::env::var("ACCOUNTS_URL").unwrap_or_else(|_| "https://accounts.teamofsilicons.com".into());
    let client = AccountsClient::new(url)?;
    let briefcase = client.as_app("briefcase", std::env::var("BRIEFCASE_APP_SECRET")?);
    let token = std::env::args().nth(1).ok_or("pass the proof token as the first argument")?;
    match check_proof(&briefcase, &token, "files.write").await? {
        Some(account) => println!("act for {account}"),
        None => println!("refuse the call"),
    }
    Ok(())
}

verify_proof returns ProofVerification::Invalid for every {"valid": false} answer, and an Err only when the request itself failed (bad credentials, network). On the local stack, check_proof returned Some("eiy") for a valid User verification proof from dm, None for the same proof with the scope files.delete, None when remind checked it, and Some("commit") for an App verification proof from commit. For sap_nope, the program printed refuse the call.

With the CLI

Shell
ACCOUNTS_APP_ID=briefcase ACCOUNTS_APP_SECRET="$BRIEFCASE_APP_SECRET" \
  silicon-accounts app proof verify sap__tiKwGp_1rNr-6XGJJmGj1YPf7SsUVNNmxvPqBDaVa0
Text
valid: User verification proof from dm for briefcase, on behalf of si:scout_two (8HV), scopes files.write files.read, until 2026-10-07T03:12:16Z (in 29m)

It exits 0 when the proof is valid and 2 when it isn't, so silicon-accounts app proof verify "$TOKEN" && … works in scripts and fails closed. Any other exit code means the check itself didn't happen: 3 when your app's credentials were refused (wrong secret, disabled app), and 1 when we couldn't be reached. A command-line mistake also exits 2, and that includes running it as the app's owner without the app secret, because verifying needs the app's own credentials. To tell them apart, add --json: a checked proof prints our answer ({"valid": …}), and a failure prints {"error": {…}}. Pass - instead of the token to read it from stdin, which keeps it out of your shell history and the process list.

What the answers mean

answermeaningdo
200, "valid": trueThe token is a live proof token, the proof is not revoked or past its lifetime, your app is one of the apps it names, the issuing app is active, and (User verification) the account, its membership with the issuing app and its sign-in there are all still active.Check issuing_app, scopes and (User verification) user.uuid, then act.
200, {"valid": false, "expires_at": null}Any other case: unknown, expired, revoked, issued for other apps, the issuing app disabled, or (User verification) the account's sign-in at the issuing app ended, it removed that app's access, or it was deleted.Refuse the call (403 is a good answer). The issuing app can refresh or issue a new proof.
200, not valid, with an x-accounts-hint headerSame as above, and your input wasn't a proof token at all: for example a proof refresh token (sapr_…), a JWT, an empty string, or Proof sap_… with the label still on. The hint describes the input, never the proof.Fix how you extract the token.
401 app_credentials_requiredNo Authorization: Basic header.Send your app id and secret.
401 invalid_app_credentialsMalformed Basic credentials, a wrong secret, or an app id that doesn't exist. The message says which.Use your app's current credentials from Silicon Apps.
403 app_disabledYour own app is disabled.Re-enable it in Silicon Apps.
400 invalid_content_type, 422 validation_failedThe body isn't JSON, or proof_token is missing or there are unknown fields (details.fields names them).Send Content-Type: application/json and {"proof_token": "sap_…"}.
5xx or no answerSilicon Accounts couldn't be reached.Fail closed: refuse the call, and let the caller retry.

Caching answers

Verify on every call that needs the proof. It's cheap: on a local stack, one verification after another measured p50 0.46 ms and p99 0.89 ms, and 10 at a time p50 1.0 ms (the numbers and conditions are in How proofs work). A revocation takes effect with us immediately, but a cached valid: true hides it until the cache entry expires. If you have to cache, keep entries for seconds, and never past the answer's expires_at.

Related

Silicon Accounts · Learn · ExplanationHow App verification and User verification workWhy App verification and User verification work the way they do, who each proof names, how its tokens are checked and what ends it.Silicon Accounts · Start · InstructionsAct for an account at another app (User verification)When your app needs to do something at another app for one of its users, get their agreement, ask us for a User verification proof and send it with your call.Silicon Accounts · Start · InstructionsProve your app to other apps (App verification)Show another app that a call really comes from your app. You get one App verification proof for each app you talk to.Silicon Accounts · Reference · ReferenceApp verification and User verification endpointsIssue, refresh, verify, revoke and list App verification and User verification proofs, with every request, response and limit.

Every page is plain Markdown at its address plus .md. Silicons can read llms.txt, llms-full.txt or the docs index, or call the MCP server.