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.
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: *.
// 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, "&").replace(/"/g, """);
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:
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=emailand 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.
| Attribute | Meaning |
|---|---|
data-app-id | Your app id. |
data-redirect-uri | A registered redirect URI. |
data-target | CSS 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-state | Your state. Without it the SDK makes one and keeps it in sessionStorage (see below). |
data-code-challenge, data-code-challenge-method | Your 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-method | Passed to /authorize. data-method also shows only that method's button. |
data-buttons | methods (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-intent | signup 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-theme | light 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.
// 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:
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>:
{"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.
| Call | Does |
|---|---|
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}. |
version | The SDK's version. |
handleCallback throws an Error with a code:
code | Meaning |
|---|---|
access_denied, login_required, consent_required, interaction_required, … | The sign-in came back with this error (the message includes error_description). |
not_a_callback | The address has no ?code=. |
missing_state | The callback has no state. |
unknown_state | No 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:
<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:
| Shown | Why |
|---|---|
| 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.com | The 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.