JavaScript

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-mount="#login"
  data-redirect-uri="https://yoursite.com/account"
  data-exchange-url="/auth/okia/exchange"
  defer
></script>
<div id="login"></div>

The dashboard's JavaScript tab prints this 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-mount no CSS selector; auto-injects an <okia-button> there
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/1.0.0.js are immutable.

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.

API

Everything lives on window.pw. This is the whole surface.

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, cause? }exchange_failed, timeout, state_mismatch, or the OAuth error name from a redirect return (invalid_request, redirect_uri_mismatch, …) Something went wrong. With no error subscriber the SDK 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:

// POST /auth/okia/exchange — the SDK sends { code, state, code_verifier, redirect_uri }
// (or { action: "logout" }). Exchange the code server-side, set your own session cookie.
import express from "express";

const AUTH_ORIGIN = "https://auth.okia.io";
const CLIENT_ID = "pw_c_YOUR_CLIENT_ID";
const CLIENT_SECRET = process.env.OKIA_CLIENT_SECRET;
const REDIRECT_URI = "https://yoursite.com/account"; // the registered page the snippet sends

const app = express();
app.use(express.json());

app.post("/auth/okia/exchange", async (req, res) => {
  const body = req.body ?? {};
  if (body.action === "logout") {
    // End your session here (req.session.destroy() with express-session).
    return 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();
  // Verify id_token against the JWKS (iss, aud = CLIENT_ID, exp, token_use === "id")
  // before trusting it — see below.
  const claims = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString("utf8"));

  // Create YOUR session for claims.sub here — express-session, or your framework's
  // signed cookie. Never a plain cookie holding the sub: anyone who types
  // "session=pw_…" into their browser would be that user.
  res.json({ user: { sub: claims.sub } });
});

app.listen(3000);

{ action: "logout" } is what pw.logout() posts; end your session and answer 200. Any other language is the same three steps: read the JSON body, make the one HTTP call above, 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.

Install to working login (~5 min)

  1. 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.
  2. 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.
  3. Paste the script tag from the dashboard into your page; add the mount element.
  4. Backend mode only: paste the exchange endpoint from the same tab (or write it from the curl call above) and set your session cookie.
  5. Load the page, click the button, complete the WhatsApp code + passkey flow. You're logged in.
JavaScript · Okia docs