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.
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):
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)"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:
// 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, "&").replace(/"/g, """);
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:
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=emailand 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>.
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, nothttps://app.example.com/login.httpsis required except forlocalhost,127.0.0.1and[::1]. You can list up to 50. http://localhost:3000andhttp://127.0.0.1:3000are different origins, and ports count.- With no allowed origins (or an unknown or disabled app), the page answers
frame-ancestors 'none'andX-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:
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:
| Parameter | Effect |
|---|---|
buttons | methods (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. |
intent | signup 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". |
method | Show only that method's button (google, apple, email, phone). Each button adds its own method= to /authorize. |
theme | Your 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:
{ 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-code | Message |
|---|---|
missing_app_id | The embed URL has no app_id. |
missing_redirect_uri | The embed URL has no redirect_uri. |
unknown_app | No app with app_id 'nope' exists in Silicon Accounts. |
app_disabled | The app 'briefcase' is disabled, so nobody can sign in to it right now. |
method_not_enabled | DM does not offer sign-in with "google". |
no_methods | Briefcase has no sign-in methods turned on. |
network_error | Silicon Accounts could not be reached (after two quiet retries). |
no_allowed_origins | Shown 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.