Skip to content

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:

  1. Generate the PKCE code_verifier + store it server-side (session/cache, key=state)
  2. An endpoint that hands the code_challenge down to the widget (e.g. /api/auth/1pass/challenge)
  3. An endpoint that exchanges the code the widget received, together with client_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_verifier to the browser. Store it under the state key in a server session/Redis/etc.
  • Single-use. Discard it immediately after the token exchange.

Verifier / challenge generation examples

Ruby (Rails):

ruby
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:

javascript
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):

bash
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

Modedata-* attributeUXWhen
Button + modal (recommended)data-logi-buttonClick the button → QR in a full-size modal (360×540)Most RPs
Inline iframedata-logi-qrA 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.

html
<!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)

bash
# 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 key

SPA-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

AttributeRequiredDescription
data-logi-qr / data-logi-buttonMarker attribute. Determines the mount mode
data-client-idThe RP's OAuth Application client_id
data-redirect-uriThe callback URL. Must exactly match the value entered at application registration
data-code-challengeThe PKCE code_challenge (BASE64URL(SHA256(verifier))). Mount aborts if missing
data-code-challenge-methodThe PKCE method. Defaults to S256 (only S256 is supported today)
data-scopeSpace-separated scopes (e.g. openid profile email)
data-stateA 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-successThe name of the global function to call on success (window[name])
data-on-errorThe name of the global function to call on error

Unimplemented attributes

  • A user-cancellation callback (data-on-cancelled) is not supported → receive it via the logi.cancelled CustomEvent.
  • data-theme / data-lang are 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).

javascript
// 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());
typepayloadMeaning
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:5173

This 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

jsx
// 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

vue
<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

svelte
<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

ThreatDefense
An unregistered site embeds the widgetwidget_origins allowlist → Content-Security-Policy: frame-ancestors
postMessage eavesdropping / spoofingThe widget restricts targetOrigin when sending to the parent. The parent must verify event.origin === "https://embed.1pass.dev"
Authorization code interceptionPKCE (code_verifier stays on the RP server only; only the code_challenge is exposed to the widget)
Cookie theft inside the iframeSameSite=None; Secure cookie + HttpOnly + a separate subdomain (embed.1pass.dev) to isolate the cookie scope
Clickjackinglogi's frame-ancestors allows only registered origins. On the RP side, we recommend reviewing X-Frame-Options on your own page
Embed attempt over HTTPThe 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

Identity가 제품의 신뢰를 만듭니다.