Widget SDK (embed.1pass.dev)
With one line of UMD JavaScript and a single <div>, the logi QR login widget appears inside your RP page. It's isolated in an iframe, separated from your RP page's cookies and DOM.
If you need more control over the UI, see the Device Flow; if a page redirect is acceptable, the standard Code Flow is simpler.
Backend work required
The Widget SDK is a confidential client + PKCE flow. Your RP backend must implement the following:
- Generate the PKCE
code_verifier+ store it server-side (session/cache, key=state) - An endpoint that hands the
code_challengedown to the widget (e.g./api/auth/1pass/challenge) - An endpoint that exchanges the
codethe widget received, together withclient_secret+code_verifier, at/oauth/token
client_secret is used on the RP backend only. Never send it to the browser.
PKCE setup (RP backend — required first step)
The widget will not mount without a PKCE code_challenge.
Rules:
code_verifier: a 43–128 character unreserved string (RFC 7636 §4.1). Recommended: 32 random bytes → BASE64URL.code_challenge:BASE64URL(SHA256(code_verifier))(S256 method).- Never send
code_verifierto the browser. Store it under thestatekey in a server session/Redis/etc. - Single-use. Discard it immediately after the token exchange.
Verifier / challenge generation examples
Ruby (Rails):
require "securerandom"
require "digest"
require "base64"
def generate_pkce_pair
verifier = SecureRandom.urlsafe_base64(32) # ~43 chars, no padding
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
[verifier, challenge]
end
# In your controller:
verifier, challenge = generate_pkce_pair
state = SecureRandom.hex(16)
session[:logi_pkce] = { state => verifier } # short-lived, e.g. 10 min TTL
render json: { state: state, code_challenge: challenge }Node.js:
import { randomBytes, createHash } from "node:crypto";
function generatePkcePair() {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}curl (for shell verification):
VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "verifier=$VERIFIER"
echo "challenge=$CHALLENGE"Two mount modes
| Mode | data-* attribute | UX | When |
|---|---|---|---|
| Button + modal (recommended) | data-logi-button | Click the button → QR in a full-size modal (360×540) | Most RPs |
| Inline iframe | data-logi-qr | A small iframe embedded directly in the RP page (320×460) | When it must always be visible, e.g. a dashboard |
Button + modal is superior for QR readability, iframe-block fallback, and avoiding layout conflicts (the same pattern as Auth0 Lock, the Kakao SDK, and Stripe Checkout).
Quick Start
Assume the RP backend issues { state, code_challenge } at /api/auth/1pass/challenge and exchanges code for a token at /api/auth/1pass/exchange.
Recommended: button + modal
<!DOCTYPE html>
<html>
<head>
<script src="https://embed.1pass.dev/widget.js" defer></script>
</head>
<body>
<div id="logi-mount"
data-logi-button
data-client-id="logi_xxxxxxxxxxxx"
data-redirect-uri="https://your-app.com/callback"
data-on-success="handleLogin"
data-on-error="handleError"></div>
<script>
// 1) Fetch state + code_challenge from the RP backend
fetch("/api/auth/1pass/challenge", { method: "POST" })
.then((r) => r.json())
.then(({ state, code_challenge }) => {
const mount = document.getElementById("logi-mount");
mount.setAttribute("data-state", state);
mount.setAttribute("data-code-challenge", code_challenge);
if (window.LogiWidget) window.LogiWidget.mountButton(mount);
});
// 2) The widget inside the modal passes the code to the parent via the logi.success event
function handleLogin({ code, state }) {
fetch("/api/auth/1pass/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state }),
}).then(() => location.assign("/dashboard"));
}
function handleError({ error, error_description }) {
console.error("logi error:", error, error_description);
}
</script>
</body>
</html>Inline iframe
Use the data-logi-qr marker instead of data-logi-button, and call LogiWidget.mountWidget(mount) instead of LogiWidget.mountButton. The rest of the attributes/callbacks are the same as above. Since there is no callback attribute for user cancellation, receive it with mount.addEventListener("logi.cancelled", ...).
Token exchange (RP backend)
# Call from the RP backend (not the browser ❌)
curl -X POST https://api.1pass.dev/oauth/token \
-d "grant_type=authorization_code" \
-d "code=$CODE_FROM_WIDGET" \
-d "redirect_uri=https://your-app.com/callback" \
-d "client_id=$LOGI_CLIENT_ID" \
-d "client_secret=$LOGI_CLIENT_SECRET" \
-d "code_verifier=$STORED_VERIFIER" # the value retrieved from the session by the state keySPA-only integration is also possible
To call /oauth/token directly from the browser without a backend, register as a public client with no client_secret. It runs PKCE-only. For the detailed procedure, see the Public Clients guide.
data-* attributes
| Attribute | Required | Description |
|---|---|---|
data-logi-qr / data-logi-button | ✅ | Marker attribute. Determines the mount mode |
data-client-id | ✅ | The RP's OAuth Application client_id |
data-redirect-uri | ✅ | The callback URL. Must exactly match the value entered at application registration |
data-code-challenge | ✅ | The PKCE code_challenge (BASE64URL(SHA256(verifier))). Mount aborts if missing |
data-code-challenge-method | — | The PKCE method. Defaults to S256 (only S256 is supported today) |
data-scope | — | Space-separated scopes (e.g. openid profile email) |
data-state | — | A state for CSRF protection. Auto-generated if omitted. To use it as the verifier-mapping key, we recommend the RP issue it itself |
data-on-success | — | The name of the global function to call on success (window[name]) |
data-on-error | — | The name of the global function to call on error |
Unimplemented attributes
- A user-cancellation callback (
data-on-cancelled) is not supported → receive it via thelogi.cancelledCustomEvent. data-theme/data-langare not supported (v1.x roadmap).
postMessage protocol
The widget passes events to the parent window via window.postMessage, and at the same time dispatches a CustomEvent of the same name on the mount container (bubbles: true).
// Approach 1: listen to window.postMessage directly
window.addEventListener("message", (event) => {
// ✅ origin verification is required
if (event.origin !== "https://embed.1pass.dev") return;
const { type, ...payload } = event.data;
switch (type) {
case "logi.success": handleLogin(payload); break; // { code, state }
case "logi.error": handleError(payload); break; // { error, error_description }
case "logi.cancelled": handleCancel(); break; // {}
}
});
// Approach 2: the CustomEvent on the mount container (the widget does the origin check for you)
const mount = document.querySelector("[data-logi-qr]");
mount.addEventListener("logi.success", (e) => handleLogin(e.detail));
mount.addEventListener("logi.error", (e) => handleError(e.detail));
mount.addEventListener("logi.cancelled", (e) => handleCancel());type | payload | Meaning |
|---|---|---|
logi.success | { code, state } | The user approved. Exchange at /oauth/token from the RP backend |
logi.error | { error, error_description } | An OAuth error. The error value is a standard OAuth error code |
logi.cancelled | {} | The user closed the widget / clicked cancel |
RP registration (console)
In the logi console (/console), add your RP domain to the application's Widget Origins.
widget_origins:
- https://your-app.com
- https://staging.your-app.com
- http://localhost:5173This value is baked into the Content-Security-Policy: frame-ancestors header of the iframe response. The browser blocks the iframe from rendering for any unregistered origin.
Framework integration examples
Assume PKCE setup (where /api/auth/1pass/challenge issues { state, code_challenge }) is in place on the RP backend.
React
// LogiQrButton.jsx
import { useEffect, useRef, useState } from "react";
export function LogiQrButton({ clientId, redirectUri, onSuccess, onError }) {
const ref = useRef(null);
const [pkce, setPkce] = useState(null);
useEffect(() => {
fetch("/api/auth/1pass/challenge", { method: "POST" })
.then((r) => r.json())
.then(setPkce);
}, []);
useEffect(() => {
if (!pkce || !ref.current || !window.LogiWidget) return;
window.__logiOnSuccess = onSuccess;
window.__logiOnError = onError;
window.LogiWidget.mountWidget(ref.current);
return () => {
delete window.__logiOnSuccess;
delete window.__logiOnError;
};
}, [pkce, onSuccess, onError]);
if (!pkce) return null;
return (
<div
ref={ref}
data-logi-qr
data-client-id={clientId}
data-redirect-uri={redirectUri}
data-code-challenge={pkce.code_challenge}
data-state={pkce.state}
data-on-success="__logiOnSuccess"
data-on-error="__logiOnError"
/>
);
}
// _app.jsx — load once
<script src="https://embed.1pass.dev/widget.js" defer />Vue 3
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
const props = defineProps(["clientId", "redirectUri"]);
const emit = defineEmits(["success", "error", "cancelled"]);
const mountRef = ref(null);
const pkce = ref(null);
onMounted(async () => {
pkce.value = await fetch("/api/auth/1pass/challenge", { method: "POST" }).then((r) => r.json());
window.__logiSuccess = (p) => emit("success", p);
window.__logiError = (p) => emit("error", p);
await Promise.resolve();
if (window.LogiWidget && mountRef.value) window.LogiWidget.mountWidget(mountRef.value);
mountRef.value?.addEventListener("logi.cancelled", () => emit("cancelled"));
});
onUnmounted(() => {
delete window.__logiSuccess;
delete window.__logiError;
});
</script>
<template>
<div
v-if="pkce"
ref="mountRef"
data-logi-qr
:data-client-id="clientId"
:data-redirect-uri="redirectUri"
:data-code-challenge="pkce.code_challenge"
:data-state="pkce.state"
data-on-success="__logiSuccess"
data-on-error="__logiError"
/>
</template>Svelte 5
<script>
import { onMount, onDestroy } from "svelte";
let { clientId, redirectUri, onSuccess, onError, onCancelled } = $props();
let mountEl;
let pkce = $state(null);
onMount(async () => {
pkce = await fetch("/api/auth/1pass/challenge", { method: "POST" }).then((r) => r.json());
window.__logiSuccess = onSuccess;
window.__logiError = onError;
queueMicrotask(() => {
if (window.LogiWidget && mountEl) window.LogiWidget.mountWidget(mountEl);
mountEl?.addEventListener("logi.cancelled", () => onCancelled?.());
});
});
onDestroy(() => {
delete window.__logiSuccess;
delete window.__logiError;
});
</script>
{#if pkce}
<div
bind:this={mountEl}
data-logi-qr
data-client-id={clientId}
data-redirect-uri={redirectUri}
data-code-challenge={pkce.code_challenge}
data-state={pkce.state}
data-on-success="__logiSuccess"
data-on-error="__logiError"
/>
{/if}Vanilla / other
Use the Quick Start example directly in your HTML as-is. Because it's a UMD bundle, no module system is required.
Security model
| Threat | Defense |
|---|---|
| An unregistered site embeds the widget | widget_origins allowlist → Content-Security-Policy: frame-ancestors |
| postMessage eavesdropping / spoofing | The widget restricts targetOrigin when sending to the parent. The parent must verify event.origin === "https://embed.1pass.dev" |
| Authorization code interception | PKCE (code_verifier stays on the RP server only; only the code_challenge is exposed to the widget) |
| Cookie theft inside the iframe | SameSite=None; Secure cookie + HttpOnly + a separate subdomain (embed.1pass.dev) to isolate the cookie scope |
| Clickjacking | logi's frame-ancestors allows only registered origins. On the RP side, we recommend reviewing X-Frame-Options on your own page |
| Embed attempt over HTTP | The iframe URL is HTTPS only. http origins are rejected (exception: http://localhost:* for development) |
Do not expose client_secret
Browser-side code never handles client_secret. In a confidential client integration, the RP backend uses client_secret + code_verifier together to exchange the token. For an integration without a backend, register as a public client.
Limitations
- On a mobile web browser you can't scan a QR shown on your own screen with the same device → for mobile, we recommend the Universal Link jump in the standard Code Flow
- In iframe-blocking environments (some corporate firewalls, ad blockers) the widget won't appear → as a fallback, we recommend also providing a standard OAuth button
- Current v0.x: design customization / themes / language tokens are not supported (v1.x roadmap)
Live demo
Production walking sample: https://api.1pass.dev/widget-demo.html. It mounts with the real 1pass-demo RP, and /embed/demo/challenge issues the PKCE challenge.
References
- Recommended architecture (RP integration)
- Troubleshooting — iframe blocked / postMessage not arriving / 401 invalid_client, etc.
- Standard Authorization Code Flow
- Device Authorization Grant
- Integration comparison
- OAuth error codes
- RFC 7636 — PKCE