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

Embed the sign-in buttons in an iframe

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

InstructionsUpdated MarkdownEdit on GitHub
On this page

The iframe puts your app's sign-in buttons on your own page. It shows one button for each method your app has turned on, in your colours and with your logo, with "Powered by Silicon Accounts" below them.

You add your site's origin to allowed_origins, then embed /embed/v1/buttons with the same parameters as an authorize request. When someone clicks a button, the whole window opens our hosted sign-in pages, and after the sign-in the browser comes back to your redirect URI. You handle that callback exactly as you would for the hosted pages.

Start by allowing the origin that will frame the buttons (scheme, host and port, no path), and register the redirect URI. Lists in a sign-in setup patch replace the whole list, so a patch of just ["http://localhost:3000"] would delete every origin and redirect URI you already have, and sign-in on those sites would stop. Add to what's there instead (with jq):

Shell
CONFIG=$(silicon-accounts app config get --json)
echo "$CONFIG" | jq '.signin_config | {
  allowed_origins: (.allowed_origins + ["http://localhost:3000"]),
  redirect_uris: (.redirect_uris + ["http://localhost:3000/callback"])
}' | silicon-accounts app config set - --expected-version "$(echo "$CONFIG" | jq .config_version)"
Text
Updated briefcase (allowed_origins, redirect_uris); the sign-in setup is now version 2.

Duplicates are dropped, so running it twice changes nothing. --expected-version refuses the patch (409 config_version_conflict) if someone changed the setup between your read and your write, instead of overwriting their change. If that happens, read and patch again.

Then serve the page. Your server makes the state and PKCE for every page view, exactly as for the hosted pages, so the callback is the same:

TypeScript
// iframe-app.ts: the sign-in buttons in an iframe, with state + PKCE made on your server.
// Node 24+: ACCOUNTS_APP_ID=briefcase ACCOUNTS_APP_SECRET=sa_app_… node iframe-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 ORIGIN = `http://localhost:${PORT}`; // in the app's allowed_origins
const REDIRECT_URI = `${ORIGIN}/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 ?? "/", ORIGIN);

  if (url.pathname === "/") {
    // A new sign-in for every page view: state + PKCE stay here, the cookie binds them to this browser.
    const state = random(32);
    const verifier = random(32);
    pending.set(state, { verifier, startedAt: Date.now() });
    const src = new URL("/embed/v1/buttons", ACCOUNTS_URL);
    src.search = new URLSearchParams({
      app_id: APP_ID,
      redirect_uri: REDIRECT_URI,
      scope: "email",
      state,
      code_challenge: createHash("sha256").update(verifier).digest("base64url"),
      code_challenge_method: "S256",
      theme: "light", // your page's theme: light, dark or auto
    }).toString();
    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>
<iframe id="silicon-accounts" title="Sign in with Silicon Accounts" src="${attr(src.toString())}"
        style="display:block;width:100%;max-width:400px;height:260px;border:0"></iframe>
<script>
  // The frame reports its height; follow it.
  addEventListener("message", (event) => {
    if (event.origin === ${JSON.stringify(new URL(ACCOUNTS_URL).origin)} && event.data?.type === "silicon-accounts:resize")
      document.getElementById("silicon-accounts").style.height = event.data.height + "px";
  });
</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 ${ORIGIN}/`));

In a browser, the frame shows the app's four methods (each a link with target="_top") and reports its height, 264 px here. Choosing email took the window to:

Text
https://accounts.teamofsilicons.com/authorize?app_id=briefcase&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=-V6HjsnYz78--44vVIH-TCym7jQBur7OcUCSaIcU7Ek&code_challenge=fo6rK7yEC-w7zkC04MxhbnUMyDW0ZPoAbQyKoiMdtTM&code_challenge_method=S256&scope=email&method=email

and after the sign-in, the callback answered Signed in as c:grace-hopper (briefcase:ptO). Your app's Embed tab on developers.teamofsilicons.com prints this iframe for your own app id and redirect URIs, with a live preview.

Allow your origin

Browsers only show the frame on pages whose origin is in your allowed_origins, because the embed page answers with Content-Security-Policy: frame-ancestors 'self' <your allowed_origins>.

Shell
curl -sI "https://accounts.teamofsilicons.com/embed/v1/buttons?app_id=briefcase&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback" \
  | grep -i -o "frame-ancestors[^;]*"
# frame-ancestors 'self' http://localhost:3000
  • An origin is scheme://host[:port] with no path: https://app.example.com, not https://app.example.com/login. https is required except for localhost, 127.0.0.1 and [::1]. You can list up to 50.
  • http://localhost:3000 and http://127.0.0.1:3000 are different origins, and ports count.
  • With no allowed origins (or an unknown or disabled app), the page answers frame-ancestors 'none' and X-Frame-Options: DENY.
  • A change takes up to 30 seconds to reach the embed page.

On an origin that isn't listed, the frame stays empty and the browser's console says why. In Chromium, for example:

Text
Framing 'https://accounts.teamofsilicons.com/' violates the following Content Security Policy directive: "frame-ancestors 'self' http://localhost:3000". The request has been blocked.

Why a list at all? A page that can frame the buttons can also dress them up (overlays, opacity tricks) to trick someone into a click. Only you decide which of your pages may do that. Every other page of Silicon Accounts refuses to be framed by anyone.

Parameters of the frame

The iframe's src takes the parameters of the authorize request. app_id (or client_id) and redirect_uri are required. state, code_challenge, code_challenge_method, scope, nonce, prompt, intent and response_type are passed on to /authorize unchanged when a button is clicked. login_hint, email and phone are dropped, because your app never hands Silicon Accounts a Carbon's email or phone. These shape the frame itself:

ParameterEffect
buttonsmethods (default): one button per enabled method, "Continue with Google", "Continue with Apple", "Continue with email", "Continue with phone number", each opening our pages on that method (Google and Apple through the Opening page). intents: a "Sign in" and a "Sign up" button that open our pages with every method.
intentsignup opens the sign-up version of our pages ("Create your {app} account"). With buttons=intents it keeps only the "Sign up" button; signin only "Sign in".
methodShow only that method's button (google, apple, email, phone). Each button adds its own method= to /authorize.
themeYour page's theme: light, dark or auto. It sets the frame's color scheme so its background stays transparent on your page. The buttons paint in light or dark when you give one; otherwise in your branding's forced theme, else (with auto) the device's theme, else light. Not passed to /authorize.

The buttons follow your app's branding (colours, corner style, button style, font, density) and its method order. Email (else phone) is the one filled button. Google and Apple stay neutral, as their own guidelines ask.

Size the frame

The page inside the frame posts its height to your page whenever it changes:

JavaScript
{ type: "silicon-accounts:resize", height: 264 }

Follow it as the example does, checking event.origin first. Or load the SDK on your page. It resizes every /embed/v1/buttons frame on the page, including frames you wrote in HTML yourself, and SiliconAccounts.mountFrame("#target", {...}) builds the iframe for you. It makes the state (and the PKCE pair with pkce: "S256") and keeps them in sessionStorage for handleCallback.

Why the click leaves your page

The buttons are links with target="_top", so the sign-in itself always runs in the whole window at accounts.teamofsilicons.com, never inside the frame. The Carbon sees the real address before typing a code, the Silicon Accounts session works without third-party cookies (which browsers block in frames), and Google and Apple, which refuse to be framed, work the same way. Your page gets the result on the redirect URI, like every other way.

When the frame shows an error

A frame that can't show buttons says why in place ("These sign-in buttons are not set up correctly"), logs the same text to the console, and marks it with data-error-code:

data-error-codeMessage
missing_app_idThe embed URL has no app_id.
missing_redirect_uriThe embed URL has no redirect_uri.
unknown_appNo app with app_id 'nope' exists in Silicon Accounts.
app_disabledThe app 'briefcase' is disabled, so nobody can sign in to it right now.
method_not_enabledDM does not offer sign-in with "google".
no_methodsBriefcase has no sign-in methods turned on.
network_errorSilicon Accounts could not be reached (after two quiet retries).
no_allowed_originsShown when the embed page is opened on its own and the app lists no allowed origins: other sites can't frame it yet.

The frame doesn't check redirect_uri against your registered list. That happens when a button is clicked, and an unregistered one stops at the hosted page with "This sign-in link is not set up right". Test a click before you ship.

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 · InstructionsDrop in the SDK snippetAdd 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.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.