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

Drop in the SDK snippet

Add one script tag and your sign-in buttons show up on your page. Set up the buttons, then finish the sign-in through your server's callback.

InstructionsUpdated MarkdownEdit on GitHub
On this page

Add the SDK with one <script> tag and it shows your sign-in buttons on your page, with the methods, colours and logo you set up for your app. Your server still makes the state and PKCE challenge, and handles the callback just as it does for the hosted pages.

Load the script from https://accounts.teamofsilicons.com/sdk/v1.js. It's about 20 KB, has no dependencies and is cached for 5 minutes. It allows cross-origin loading with Access-Control-Allow-Origin: *.

TypeScript
// sdk-app.ts: the SDK snippet renders the buttons; state + PKCE are made on your server.
// Node 24+: ACCOUNTS_APP_ID=briefcase ACCOUNTS_APP_SECRET=sa_app_… node sdk-app.ts, then open http://localhost:3000/
import { createServer, type ServerResponse } from "node:http";
import { createHash, randomBytes } from "node:crypto";

const ACCOUNTS_URL = process.env.ACCOUNTS_URL ?? "https://accounts.teamofsilicons.com";
const APP_ID = process.env.ACCOUNTS_APP_ID ?? "briefcase";
const APP_SECRET = process.env.ACCOUNTS_APP_SECRET ?? "";
const PORT = Number(process.env.PORT ?? 3000);
const REDIRECT_URI = `http://localhost:${PORT}/callback`; // in the app's redirect_uris

const pending = new Map<string, { verifier: string; startedAt: number }>();
const random = (bytes: number) => randomBytes(bytes).toString("base64url");
const text = (res: ServerResponse, status: number, body: string) =>
  res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(body);
const attr = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);

  if (url.pathname === "/") {
    const state = random(32);
    const verifier = random(32);
    pending.set(state, { verifier, startedAt: Date.now() });
    const challenge = createHash("sha256").update(verifier).digest("base64url");
    res.writeHead(200, {
      "Content-Type": "text/html; charset=utf-8",
      "Set-Cookie": `signin_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`,
    });
    return res.end(`<!doctype html><title>Sign in</title>
<div id="silicon-accounts" style="max-width:400px"></div>
<script src="${ACCOUNTS_URL}/sdk/v1.js" async
        data-app-id="${attr(APP_ID)}"
        data-redirect-uri="${attr(REDIRECT_URI)}"
        data-target="#silicon-accounts"
        data-scope="email"
        data-state="${attr(state)}"
        data-code-challenge="${attr(challenge)}"
        data-code-challenge-method="S256"></script>`);
  }

  if (url.pathname === "/callback") {
    // Exactly the hosted pages' callback: check the state, exchange the code with the verifier.
    const error = url.searchParams.get("error");
    if (error) return text(res, 400, `Sign-in ended without signing in: ${error}`);
    const state = url.searchParams.get("state") ?? "";
    const cookieState = /(?:^|;\s*)signin_state=([^;]+)/.exec(req.headers.cookie ?? "")?.[1];
    const started = pending.get(state);
    pending.delete(state);
    if (!started || cookieState !== state || Date.now() - started.startedAt > 60 * 60_000) {
      return text(res, 400, "This sign-in was not started in this browser (or it expired).");
    }
    const response = await fetch(new URL("/v1/oauth/token", ACCOUNTS_URL), {
      method: "POST",
      headers: {
        Authorization: `Basic ${Buffer.from(`${APP_ID}:${APP_SECRET}`).toString("base64")}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code: url.searchParams.get("code") ?? "",
        redirect_uri: REDIRECT_URI,
        code_verifier: started.verifier,
      }),
    });
    const tokens = await response.json();
    if (!response.ok) return text(res, 502, `${tokens.error}: ${tokens.error_description}`);
    return text(res, 200, `Signed in as ${tokens.account.id} (${tokens.membership_id})`);
  }

  text(res, 404, "Not found");
}).listen(PORT, () => console.log(`Open http://localhost:${PORT}/`));

In a browser, the page shows "Continue with Google", "Continue with Apple", "Continue with email" and "Continue with phone" (briefcase's methods, in its order) and the "Powered by Silicon Accounts" line. Choosing email went to:

Text
https://accounts.teamofsilicons.com/authorize?app_id=briefcase&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&state=GQe4rfj-DYDcsc1QSee9P0knbkY4YaF83btELqx-7Lo&code_challenge=9oFWaKZEmIFd4oWb4yVL2fRCaobJFwMKi1wdbMs-A50&code_challenge_method=S256&scope=email&method=email

and the callback answered Signed in as c:grace-hopper (briefcase:ptO).

The SDK puts its buttons in a Shadow DOM, so their styles stay separate from your page. It uses a constructed stylesheet, which keeps the button styles working under a strict style-src.

The buttons don't need an allowed_origins entry: they belong to your page, and clicking one navigates to /authorize. An iframe does need an allowed origin, whether you use mountFrame or write the iframe yourself.

If your page sets a Content-Security-Policy, allow the script and its request with script-src https://accounts.teamofsilicons.com; connect-src https://accounts.teamofsilicons.com. Add frame-src https://accounts.teamofsilicons.com when you use mountFrame.

Script attributes

With data-app-id and data-redirect-uri, the script renders the buttons by itself. Without data-app-id, it only defines window.SiliconAccounts.

AttributeMeaning
data-app-idYour app id.
data-redirect-uriA registered redirect URI.
data-targetCSS selector of the element to render into; without it the buttons go right after the script tag. A selector that matches nothing logs an error.
data-stateYour state. Without it the SDK makes one and keeps it in sessionStorage (see below).
data-code-challenge, data-code-challenge-methodYour PKCE challenge (S256, or plain).
data-pkce="S256"Have the SDK make the PKCE pair itself when you pass no challenge.
data-scope, data-nonce, data-prompt, data-methodPassed to /authorize. data-method also shows only that method's button.
data-buttonsmethods (default): a button per method your app turned on ("Continue with Google", "Continue with Apple", "Continue with email", "Continue with phone number"), each opening our pages on that method; Google and Apple go through the Opening page first. intents: a "Sign in" and a "Sign up" button that open our pages with every method.
data-intentsignup opens the sign-up version of our pages ("Create your {app} account"); with data-buttons="intents" it keeps only the "Sign up" button. Default signin.
data-themelight or dark paints the buttons that way. Otherwise your branding's forced theme wins, else the SDK reads the page behind the buttons (the first opaque background, else the page's color-scheme) and follows it when your page switches theme.

Email (else phone) is the one filled button. Google and Apple stay neutral, as their guidelines ask. Colours, corners, button style, font and density come from your app's branding.

Let the SDK make state and PKCE

A static site with no session store can let the browser keep the sign-in. With data-pkce="S256" and no data-state, the SDK makes the state, the PKCE pair (and a nonce when scope includes openid) and saves them in sessionStorage before leaving the page. Your callback page calls SiliconAccounts.handleCallback(), which checks the state against that saved record and hands back the code and its verifier. Your server only exchanges them, so the app secret still never reaches the browser. Register the callback page (http://localhost:3000/signed-in here) as a redirect URI.

TypeScript
// sdk-static.ts: the SDK makes state + PKCE in the browser; your server only exchanges the code.
// Node 24+: ACCOUNTS_APP_ID=briefcase ACCOUNTS_APP_SECRET=sa_app_… node sdk-static.ts, then open http://localhost:3000/
import { createServer } from "node:http";

const ACCOUNTS_URL = process.env.ACCOUNTS_URL ?? "https://accounts.teamofsilicons.com";
const APP_ID = process.env.ACCOUNTS_APP_ID ?? "briefcase";
const APP_SECRET = process.env.ACCOUNTS_APP_SECRET ?? "";
const PORT = Number(process.env.PORT ?? 3000);
const REDIRECT_URI = `http://localhost:${PORT}/signed-in`; // listed in the app's redirect_uris

const pages: Record<string, string> = {
  // The buttons. data-pkce="S256": the SDK creates state + PKCE and keeps them in sessionStorage.
  "/": `<!doctype html><title>Sign in</title>
<div id="silicon-accounts" style="max-width:400px"></div>
<script src="${ACCOUNTS_URL}/sdk/v1.js" async data-app-id="${APP_ID}"
        data-redirect-uri="${REDIRECT_URI}" data-target="#silicon-accounts"
        data-scope="email" data-pkce="S256"></script>`,
  // The callback page: the SDK checks the state, hands back the code and its verifier.
  "/signed-in": `<!doctype html><title>Signing in…</title><pre id="out">Signing in…</pre>
<script src="${ACCOUNTS_URL}/sdk/v1.js"></script>
<script type="module">
  const out = document.getElementById("out");
  try {
    const { code, codeVerifier } = SiliconAccounts.handleCallback();
    const response = await fetch("/exchange", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ code, codeVerifier }),
    });
    out.textContent = await response.text();
  } catch (error) {
    out.textContent = \`\${error.code}: \${error.message}\`; // access_denied, unknown_state, …
  }
</script>`,
};

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
  if (req.method === "POST" && url.pathname === "/exchange") {
    let body = "";
    for await (const chunk of req) body += chunk;
    const { code, codeVerifier } = JSON.parse(body);
    const response = await fetch(new URL("/v1/oauth/token", ACCOUNTS_URL), {
      method: "POST",
      headers: {
        Authorization: `Basic ${Buffer.from(`${APP_ID}:${APP_SECRET}`).toString("base64")}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: codeVerifier }),
    });
    const tokens = await response.json();
    if (!response.ok) return res.writeHead(400).end(`${tokens.error}: ${tokens.error_description}`);
    // Start your own session for tokens.account.uuid here (an HttpOnly cookie), keep the tokens server side.
    return res.writeHead(200).end(`Signed in as ${tokens.account.id} (${tokens.membership_id})`);
  }
  const page = pages[url.pathname];
  if (!page) return res.writeHead(404).end();
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(page);
}).listen(PORT, () => console.log(`Open http://localhost:${PORT}/`));

After a sign-in, the callback page shows Signed in as c:sdk2-docs (briefcase:jTb). Reloading it shows:

Text
unknown_state: Silicon Accounts: no sign-in with this state was started in this browser tab, or it was already finished. Start the sign-in again (never reuse a callback address).

because handleCallback removes the saved record, so a callback works only once. The saved record is JSON under the key silicon-accounts:auth:<state>:

JSON
{"state": "…", "code_verifier": "…", "nonce": null, "redirect_uri": "http://localhost:3000/signed-in", "app_id": "briefcase", "created_at": 1791341046000}

sessionStorage belongs to one tab, so the sign-in must finish in the tab that started it. That's also what makes a link from someone else fail the state check. Server-made state (the first example) works across tabs and survives blocked storage, so prefer it when you have a server session. Keep /exchange same-origin: a JSON body can't be sent cross-site without a CORS preflight, and your server never answers one.

window.SiliconAccounts

Every option is optional when the script tag already carries it, and options override attributes. Options use camelCase: appId, redirectUri, state, codeChallenge, codeChallengeMethod, scope, nonce, prompt, intent ("signin" or "signup"), method, buttons ("methods" or "intents", for renderButtons), pkce ("S256" or true), theme. There is no email or phone option. loginHint, data-login-hint, email and phone are ignored with one console warning, because your app never hands Silicon Accounts a Carbon's email or phone; the Carbon types it on our pages.

CallDoes
authorizeUrl(options)Returns the /authorize URL; no side effects. Throws when appId or redirectUri is missing, or method isn't google, apple, email or phone.
signIn(options)Sends this window to sign in, making the state (and PKCE when pkce is set) and saving them as above. Use it for your own buttons: signIn({method: "google"}) for "Continue with Google", signIn({intent: "signup"}) for "Sign up".
renderButtons(target, options)Renders the buttons into target (an element or a selector). Resolves to {app, destroy()}, where app is your public sign-in config; rejects after drawing the reason in place.
mountFrame(target, options)Appends the iframe version (/embed/v1/buttons), sized to its content. Resolves to {iframe, destroy()}. Needs your origin in allowed_origins.
handleCallback(url?)On your callback page: reads ?code=&state= (or ?error=), matches the state to a sign-in this tab started, and returns {code, state, codeVerifier, nonce, redirectUri, appId}.
versionThe SDK's version.

handleCallback throws an Error with a code:

codeMeaning
access_denied, login_required, consent_required, interaction_required, …The sign-in came back with this error (the message includes error_description).
not_a_callbackThe address has no ?code=.
missing_stateThe callback has no state.
unknown_stateNo sign-in with this state was started in this tab, or it was already finished.

The script fires silicon-accounts:ready on document once it has loaded (event.detail is the API), so code that runs before an async script finishes can wait for it:

HTML
<button id="sign-in">Sign in</button>
<script src="https://accounts.teamofsilicons.com/sdk/v1.js" async></script>
<script>
  document.addEventListener("silicon-accounts:ready", ({ detail: sdk }) => {
    document.getElementById("sign-in").onclick = () =>
      sdk.signIn({ appId: "briefcase", redirectUri: "https://briefcase.example/callback", pkce: "S256", method: "google" });
  });
</script>

When the buttons don't appear

Problems are drawn where the buttons would be ("These sign-in buttons are not set up correctly") and logged to the console in the same words:

ShownWhy
data-app-id is missing.No data-app-id and no appId option.
data-redirect-uri is missing.No data-redirect-uri and no redirectUri option.
No app with app_id 'nope' exists in Silicon Accounts.The service's own answer for your public config (GET /v1/apps/{app_id}/public). A disabled app reads "The app '…' is disabled, so nobody can sign in to it right now."
Briefcase has no sign-in methods turned on. / Briefcase does not offer sign-in with "phone".Turn the method on in your sign-in setup, or drop data-method.
could not reach https://accounts.teamofsilicons.comThe config fetch failed three times (it retries after 0.5 s and 1.5 s, so a page that is navigating away never reports it). Check connect-src.

Like the iframe, the snippet doesn't check data-redirect-uri against your registered list until a button is clicked, and an unregistered one stops at the hosted page.

Related

Silicon Accounts · Start · InstructionsSign in with the hosted pagesSend your users to our sign-in pages and bring them back to your app. Set up the callback, check the state and exchange the code on your server.Silicon Accounts · Start · InstructionsEmbed the sign-in buttons in an iframePut your app's sign-in buttons on your own page in an iframe. Allow your site's origin, add the frame and handle the return like any other sign-in.Silicon Accounts · Learn · ExplanationHow the hosted sign-in worksA browser sign-in from your app to us and back, step by step, and why redirect URLs, state, PKCE, consent and provider settings matter.Silicon Accounts · Start · InstructionsBrand the sign-in pagesMake the sign-in pages, the iframe and the buttons look like your app, with your colours, fonts, logo and layout.

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.