Login widget
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.
<script async src="https://insy.io/oauth/widget.js"
data-client-id="oac_your_client_id"
data-scope="memberships.read"
data-on-auth="onInsyAuth"></script>Copy and paste
The button renders inline where the script tag sits — place the tag where you want it.
<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>
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.
<script src="https://insy.io/oauth/widget.js" data-on-auth="myApp.onInsyAuth" ...></script>
<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.
Define a function on window and name it in the attribute.
<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>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.
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
{
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
{
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
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.
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.
The user signs in and consents
Everything credential-related happens on Insy’s own origin. Your page never sees the user’s password.
The popup lands on a callback page Insy hosts
That page posts the authorization code back to the opener — your page — and closes.
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. 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
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
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.
Security
If your application has a backend that can hold a secret, the server-side flow with a confidential client keeps tokens out of the browser entirely and is the stronger option.