One script tag on any page. IIFE, zero dependencies, under 15 KB gzipped.
Embed
<script
src="https://auth.okia.io/js/v1.js"
data-client-id="pw_c_YOUR_CLIENT_ID"
data-redirect-uri="https://yoursite.com/account"
data-exchange-url="/auth/okia/exchange"
defer
></script>
<!-- Put this element where the button should appear -->
<okia-button></okia-button>
The script tag can go anywhere — the head, a footer, a tag manager. Only the element's position matters: the button renders exactly where <okia-button> sits, before or after the tag. The dashboard's JavaScript tab prints both with your client ID and your registered page filled in. A browser-only site (client type public) gets the same tag without data-exchange-url — see SPA mode below.
| Attribute | Required | Meaning |
|---|---|---|
data-client-id |
yes | Your site's client ID (pw_c_…) from the dashboard |
data-exchange-url |
no | Presence = backend mode: the SDK posts the auth code here and your endpoint does the token exchange. Absence = SPA mode: PKCE runs in the browser and tokens stay in memory |
data-redirect-uri |
no | The registered page people are sent back to. Set it to the page you registered in the dashboard so the same snippet works on every page of the site. Defaults to the page the snippet runs on, query stripped — register that exact URL if you leave it off |
data-scope |
no | Defaults to openid profile |
data-exchange-url must be same-origin. The SDK refuses a cross-origin value outright: that request carries the PKCE verifier, which is what makes the auth code redeemable.
/js/v1.js always serves the latest 1.x and is cached for five minutes. Pinned URLs like /js/v1.1.0.js are immutable: each pinned build is committed to the repository when it is released and never rebuilt, so the bytes you tested are the bytes you keep.
If your site sends a strict Content-Security-Policy, allow the auth domain in both script-src and connect-src:
script-src 'self' https://auth.okia.io; connect-src 'self' https://auth.okia.io
Do not send Cross-Origin-Opener-Policy: same-origin on the page that calls pw.login() — it severs window.opener, so the popup can never hand the result back. The SDK detects this and falls back to a redirect, but you lose the popup.
The SDK auto-selects popup (desktop, on user gesture) or redirect (mobile, or any blocked popup). Redirect is the baseline — never assume the popup.
The button
<okia-button> is a web component with its own shadow DOM: your stylesheet cannot reach in and its styles cannot leak out. It renders "Sign in" at once and upgrades to "Sign in with Face ID" or "Sign in with fingerprint" once the browser confirms a platform authenticator exists. A click calls pw.login(). A failed sign-in shows "Couldn't sign in — try again" on the button for four seconds (the error event fires too).
| Attribute | Values | Default |
|---|---|---|
theme |
light — black button · dark — white button, for a dark page · auto — follows the visitor's OS, so use it only on a page that does the same |
light |
size |
36 · 40 · 48 — the height in pixels |
40 |
Colours and radius are CSS custom properties, set on the element or any ancestor:
okia-button { --okia-button-bg: #1d4ed8; --okia-button-fg: #fff; --okia-button-radius: 999px; }
The element is inline-block; for a full-width button add okia-button { display: block }. The focus ring cannot be themed away.
Still supported: data-mount="#selector" on the script tag (the 1.0 embed) appends one <okia-button> inside the first element the selector matches, retrying once at DOMContentLoaded. Prefer placing the element yourself — a selector that misses renders nothing, and a <div id="login"> pasted through a header or footer injector lands wherever the injector writes.
The SDK needs a secure context — https://, or localhost while developing. On a plain-http page it renders nothing and says so in the console: passkeys and PKCE are not available there.
API
Everything lives on window.pw. This is the whole surface.
The tag is defer, so window.pw exists from DOMContentLoaded. A plain inline <script>pw.on(…)</script> right below the tag runs too early — put your code in a DOMContentLoaded listener, or in a defer script of your own:
<script>
document.addEventListener('DOMContentLoaded', () => {
pw.on('login', (user) => {
// user.sub — or null when your exchange endpoint answered without one
});
});
</script>
pw.getUser() answers a subscriber that arrived after the login event.
| Method | Does |
|---|---|
pw.init(opts) |
Manual init when you skip the data-* attributes |
pw.login() |
Starts login (popup or redirect) |
pw.logout() |
Ends the session on this site (not global logout) |
pw.getUser() |
Current user (sub, claims) or null |
pw.getToken() |
Current access token (SPA mode: held in memory only) |
pw.on(event, cb) |
Subscribe to auth events; returns an unsubscribe function |
Events
| Event | Payload | When |
|---|---|---|
login |
the user — { sub, ...claims } — or null when your exchange endpoint answered without one |
The exchange succeeded. In backend mode your endpoint has set its cookie by now |
logout |
none | pw.logout() was called. Ends the session on this site only |
cancelled |
none, or { code: "access_denied" } on a redirect return |
The user closed the popup or declined on the sheet |
error |
{ code, description?, cause? } — exchange_failed, timeout, state_mismatch, or the OAuth error name from a redirect return (invalid_request, redirect_uri_mismatch, …) with its error_description as description |
Something went wrong. The button says "Couldn't sign in — try again" for four seconds; with no error subscriber the SDK also prints it with console.error('[okia] <code>'), so a broken integration is never silent |
The SDK also logs, once per page load: any requested scope the site cannot be granted — for example phone on a site that is not yet verified — and, while the site is in test mode, a warning plus the exact redirect_uri it will send (console.info). A live site's console stays quiet.
Backend handoff contract
In backend mode your site implements exactly one endpoint. It receives the auth code and redirect URI from the SDK, exchanges them, verifies the ID token, and sets your own session cookie. Any language works; the exchange is one HTTP call:
curl -X POST https://auth.okia.io/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=CODE_FROM_SDK \
-d redirect_uri=REDIRECT_URI_FROM_SDK \
-d code_verifier=PKCE_VERIFIER_FROM_SDK \
-d client_id=pw_c_YOUR_CLIENT_ID \
-d client_secret=pw_cs_YOUR_CLIENT_SECRET
PKCE is required for every client, confidential ones included — code_verifier is never optional.
The dashboard prints this endpoint ready to paste — in the setup guide's JavaScript tab with your client ID, registered page and (while it still holds it) your secret, and on the site's Connectors tab with a $CLIENT_SECRET placeholder. The same template, for Node/Express — one server for the page and the exchange:
// POST /auth/okia/exchange — the SDK sends { code, state, code_verifier, redirect_uri }
// (or { action: "logout" }). Exchange the code, verify the ID token, start your session.
//
// npm install express express-session jose. Pages go in ./public: account.html answers /account.
import express from "express";
import session from "express-session";
import { createRemoteJWKSet, jwtVerify } from "jose";
const AUTH_ORIGIN = "https://auth.okia.io";
const CLIENT_ID = "pw_c_YOUR_CLIENT_ID";
const CLIENT_SECRET = process.env.OKIA_CLIENT_SECRET ?? "$CLIENT_SECRET";
// Must equal what the snippet sends — the registered page, https://yoursite.com/account
const REDIRECT_URI = "https://yoursite.com/account";
const JWKS = createRemoteJWKSet(new URL(`${AUTH_ORIGIN}/.well-known/jwks.json`));
const app = express();
app.use(express.json());
// A signed, httpOnly cookie naming a server-side session. The default memory
// store is for development — give it a real store before production.
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
// Serve the page from here, over http(s): opened as a file:// the page has no
// origin, and the snippet refuses to start.
app.use(express.static("public", { extensions: ["html"] }));
app.post("/auth/okia/exchange", async (req, res) => {
const body = req.body ?? {};
if (body.action === "logout") {
return req.session.destroy(() => res.json({ ok: true }));
}
if (!body.code || !body.code_verifier || body.redirect_uri !== REDIRECT_URI) {
return res.status(400).json({ error: "bad_request" });
}
const tokenRes = await fetch(`${AUTH_ORIGIN}/oauth/token`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: body.code,
redirect_uri: body.redirect_uri,
code_verifier: body.code_verifier,
}),
});
if (!tokenRes.ok) return res.status(502).json({ error: "exchange_failed" });
const tokens = await tokenRes.json();
// Signature against the JWKS, iss, aud and exp — then token_use: an access
// token is signed by the same key, and must never pass as proof of who this is.
let claims;
try {
({ payload: claims } = await jwtVerify(tokens.id_token, JWKS, {
issuer: AUTH_ORIGIN,
audience: CLIENT_ID,
algorithms: ["ES256"],
}));
} catch {
return res.status(401).json({ error: "invalid_token" });
}
if (claims.token_use !== "id") return res.status(401).json({ error: "invalid_token" });
// A fresh session id for the signed-in user, never the anonymous one.
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: "session_failed" });
req.session.sub = claims.sub;
res.json({ user: { sub: claims.sub } });
});
});
app.listen(3000); // open http://localhost:3000 — never the file itself
{ action: "logout" } is what pw.logout() posts; end your session and answer 200. Any other language is the same four steps: read the JSON body, make the one HTTP call above, verify the ID token, create a session the way your framework signs them.
Response: id_token + access_token (ES256 JWTs, 15 min) + refresh_token (opaque, rotates on every use).
Verify the ID token against https://auth.okia.io/.well-known/jwks.json: check iss, aud = your client ID, exp, and token_use === "id". The auth code is single-use and expires in 60 seconds — exchange it immediately, never store it.
SPA mode (no backend)
Leave data-exchange-url off and the browser does the exchange itself. This works only for a public client (client type public in the dashboard — PKCE only, no secret), and only from an origin you have registered under the site's allowed origins. The exchange is refused, unreadably, from anywhere else.
Two things to know before choosing it:
- You get no refresh token. A long-lived credential in page memory on your origin is exactly what a cross-site scripting bug would steal, so the browser exchange does not return one.
- Tokens are gone on reload, because they only ever lived in memory. That costs your users nothing: their Okia session lasts 30 days, so
pw.login()completes as a single tap without another code or biometric prompt.
A confidential client never receives these headers, at any origin — a browser must never be in a position to send a client secret. Use backend mode for those.
The browser exchange is a plain cross-origin POST, so it normally needs no preflight. A script that adds headers to outgoing requests — APM and RUM tracing do (traceparent, sentry-trace) — makes the browser ask first, and both the token endpoint and /sdk/config answer that preflight for whatever headers it names. Tracing can stay on; the token response itself is still readable only from a registered origin of a public client.
Install to working login (~5 min)
- Dashboard → create the site: paste its address, choose "Any website" and name the page the button sits on. That registers the page and its origin, and shows the client ID (and secret, if backend mode) once.
- Moved the button since? Check the redirect URI and origin under the site's Settings — the dashboard also lists any address your site tried that was not registered, with a button to add it.
- Paste the script tag from the dashboard into your page, and put
<okia-button></okia-button>where the button should appear. - Backend mode only: paste the exchange endpoint from the same tab (or write it from the curl call above) and set your session cookie.
- Load the page over
http(s)://—http://localhostwhile developing, never afile://— click the button, complete the WhatsApp code + passkey flow. You're logged in.