---
title: Login widget
description: Add Sign in with Insy to any page with a single script tag — a public OAuth client that runs PKCE in the browser and returns tokens to your code.
---

The login widget is a drop-in `script` tag that renders a Sign in with Insy button and completes the
whole OAuth flow in the browser. It is a public client and uses PKCE, so it needs no client secret
and no backend on your side.

## Button builder

Pick a look and copy the tag. The preview uses the widget's real styles; it does not sign anyone in.

<WidgetConfigurator />

## Copy and paste

The button renders inline where the script tag sits — place the tag where you want it.

```html title="index.html"
<script
  async
  src="https://insy.io/oauth/widget.js"
  data-client-id="oac_9f2c41e7a83b06d5c4e19f27"
  data-scope="memberships.read"
  data-lang="en"
  data-size="md"
  data-on-auth="handleInsyAuth"
></script>

<script>
  function handleInsyAuth(result) {
    if (!result.ok) {
      console.warn("Insy sign-in failed:", result.error, result.errorDescription);
      return;
    }
    // Send the token to your own backend. Do not trust the browser with privileged work.
    fetch("/api/insy/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ accessToken: result.accessToken }),
    });
  }
</script>
```

> **Always set data-scope**
>
> The authorize endpoint requires a scope. A widget without `data-scope` will not complete the flow.
> See [Scopes](/developers/oauth/scopes) for the two available values.

## Attributes

| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `data-client-id` | string | — | **Required.** Your public client ID, in the form `oac_` plus 24 hex characters. |
| `data-scope` | string | — | Space-separated scopes. Always set this; the server requires a scope. |
| `data-size` | `sm` \| `md` \| `lg` | `md` | Button size. `small`, `medium` and `large` are also accepted. |
| `data-lang` | `pl` \| `en` | `pl` | Button label language. |
| `data-text` | string | — | Overrides the button label entirely. Ignores `data-lang`. |
| `data-radius` | number | — | Corner radius in CSS pixels. |
| `data-on-auth` | string | — | The name of a function on `window` that receives the result. |

### data-on-auth is a name, not an expression

The value is resolved as a dotted path against `window`, so `data-on-auth="myApp.onInsyAuth"` finds
`window.myApp.onInsyAuth`. It is never evaluated as JavaScript, which is what lets the widget run
under a strict Content Security Policy. Anything that is not a plain dotted path — a call, an arrow
function, an inline statement — will not resolve.

```html title="Valid"
<script src="https://insy.io/oauth/widget.js" data-on-auth="myApp.onInsyAuth" ...></script>
```

```html title="Will not work"
<script src="https://insy.io/oauth/widget.js" data-on-auth="onInsyAuth(event)" ...></script>
```

## Receiving the result

There are two ways to get the result, and both fire for every attempt. Pick whichever fits your
codebase — you do not need both.

**data-on-auth callback**

Define a function on `window` and name it in the attribute.

```html title="callback.html"
<script>
  window.handleInsyAuth = function (result) {
    if (result.ok) {
      console.log("scope granted:", result.scope);
      console.log("expires in:", result.expiresIn, "seconds");
    } else {
      console.warn(result.error, result.errorDescription);
    }
  };
</script>

<script
  async
  src="https://insy.io/oauth/widget.js"
  data-client-id="oac_9f2c41e7a83b06d5c4e19f27"
  data-scope="memberships.read products.read"
  data-on-auth="handleInsyAuth"
></script>
```

**insy-auth DOM event**

The widget also dispatches a bubbling `CustomEvent` named `insy-auth`. Its `detail` is the same
object the callback receives. Use this if you would rather not attach anything to `window` — it
suits frameworks where a module-scoped listener is cleaner.

```js title="listener.js"
document.addEventListener("insy-auth", async (event) => {
  const result = event.detail;

  if (!result.ok) {
    showError(result.error);
    return;
  }

  await fetch("/api/insy/session", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ accessToken: result.accessToken }),
  });
});
```

Because the event bubbles, a listener on `document` catches results from every widget on the page.
Attach the listener before the widget script loads so you cannot miss an early result.

### The success object

```js title="result when ok is true"
{
  ok: true,
  state: "…",           // the state value tied to this attempt
  accessToken: "…",     // opaque; send it as Authorization: Bearer <token>
  refreshToken: "…",    // opaque; 30 days, sliding
  expiresIn: 3600,      // seconds until the access token expires
  scope: "memberships.read",
  tokenType: "Bearer"
}
```

Both tokens are opaque random strings. Do not try to decode or parse them — they carry no claims.
`scope` reflects what was actually granted, which is what you should branch on, not what you asked
for.

### The failure object

```js title="result when ok is false"
{
  ok: false,
  error: "popup_blocked",
  errorDescription: "…"
}
```

| `error` | Cause | What to do |
| --- | --- | --- |
| `popup_blocked` | The browser blocked the popup, usually because the click was not treated as a user gesture. | Ask the user to allow popups, or trigger the widget directly from a real click rather than from timers or async code. |
| `closed_by_user` | The user closed the popup before finishing. | Treat as a cancel. Leave the UI in its signed-out state and let them retry. |
| `insecure_context` | The page is not HTTPS and not `localhost`, so Web Crypto is unavailable and PKCE cannot run. | Serve the page over HTTPS. There is no fallback — PKCE is mandatory for public clients. |
| `verifier_lost` | The PKCE verifier was gone when the code came back, typically because the page reloaded or storage was cleared mid-flow. | Start a fresh attempt. Do not navigate or reload the opener while the popup is open. |
| `no_code` | The callback returned without an authorization code. | Retry. If it persists, check that the origin the page runs on is registered. |
| `token_request_failed` | The code-for-token exchange was rejected. | Check the client ID, that the client is registered as **public**, and that the requested scopes are granted to it. |
| `access_denied` | The user declined on the consent screen. | Treat as a cancel, same as `closed_by_user`. |

`error` may also carry any other OAuth error returned by the authorize endpoint, such as
`invalid_scope` for a scope your client is not registered for. Handle unknown values with a generic
failure path rather than assuming the list above is exhaustive at runtime.

## How the popup flow works

1. **The widget prepares a PKCE pair**

    On click it generates a random `code_verifier` and derives the S256 `code_challenge` using Web
    Crypto. The verifier stays on your page and is never transmitted.

2. **A popup opens the authorize URL**

    The popup goes to `https://api.insy.io/oauth/authorize` carrying the challenge, your client ID,
    the scope and a state value.

3. **The user signs in and consents**

    Everything credential-related happens on Insy's own origin. Your page never sees the user's
    password.

4. **The popup lands on a callback page Insy hosts**

    That page posts the authorization code back to the opener — your page — and closes.

5. **Your page exchanges the code**

    The widget calls `POST /oauth/token` with the code and the verifier it kept, then delivers the
    result through `data-on-auth` and the `insy-auth` event.

This split is what makes a browser-only client safe: the authorization code is useless without the
matching `code_verifier`, which never leaves your page, so an attacker who intercepts it — from a
log, a referrer header, or a malicious extension reading the popup URL — cannot redeem it. The code
is also single-use and expires after 60 seconds.

## Registering web origins

The widget only runs on origins registered on the client at
[Account → Developer → OAuth clients](https://insy.io/account/developer/oauth-clients). An origin
is the scheme, host and port together.

Each entry is either an exact origin or a host with one leading `*.` label, which covers every
subdomain beneath it at any depth — the latter exists for per-branch preview deploys. The scheme and
the port are never wildcarded.

You do not register a redirect URI for the widget. The flow always returns to the callback page Insy
hosts at `https://insy.io/oauth/widget-callback`, accepted for any client with at least one web
origin registered — your web origins are what gate where the widget runs.

### Exact origins

```text title="Registered"
https://shop.example
https://app.shop.example
http://localhost:5173
```

`https://shop.example` matches only `https://shop.example`. It does not cover
`https://www.shop.example`, `http://shop.example` or `https://shop.example:8443` — register each one
you actually use.

### Wildcard subdomains

```text title="Registered"
https://*.vercel.app
```

| Origin | Matches | Why |
| --- | --- | --- |
| `https://my-app-git-main.vercel.app` | Yes | Any host under the suffix matches. |
| `https://preview-42.vercel.app` | Yes | Same. |
| `https://deep.nested.vercel.app` | Yes | The wildcard matches at any depth, not just one label. |
| `https://vercel.app` | No | The apex is not covered by a wildcard label. Register it separately. |
| `http://my-app.vercel.app` | No | The scheme is never wildcarded. |
| `https://my-app.vercel.app:8443` | No | The port is never wildcarded. |

If you need the apex as well as the previews, register both `https://vercel.app` and
`https://*.vercel.app`.

Every host under the suffix is authorised, at every depth, to run the widget and to call the token
endpoint cross-origin. Register the narrowest suffix you control — on a shared preview domain that
means asking for exact origins instead.

> **Register your local dev origin too**
>
> The widget runs on `localhost` without HTTPS, because `localhost` is a secure context. Ask for your
> dev origin — including the port, for example `http://localhost:5173` — at registration time so you
> are not blocked on the first day.

## Security

> **Tokens land in the browser**
>
> The widget hands the access token and the refresh token to JavaScript running on your page. Treat
> them as user-scoped credentials with the same care you would give a session cookie:
>
> - **Do privileged work on your own server.** Post the access token to your backend and let the
>   backend call Insy. Do not build user-facing features that assume the browser is a trusted caller.
> - **Avoid persisting the refresh token in `localStorage`.** It is readable by any script on the
>   page, including anything a compromised dependency injects, and it is valid for 30 days.
> - **Scope down.** Ask only for what the page genuinely needs. A widget that only checks membership
>   status has no reason to hold `products.read`.
> - **Verify server-side.** Anything the browser tells your backend about the user should be
>   re-derived from the token via `GET /oauth/userinfo`, keyed on `sub`.

If your application has a backend that can hold a secret, the [server-side flow](/developers/oauth/server-side)
with a confidential client keeps tokens out of the browser entirely and is the stronger option.
