Troubleshooting
We've organized the errors you'll commonly run into as symptom → cause → fix. The higher up, the more frequent.
Widget (Widget SDK)
The QR code's finder pattern is cut off and won't scan
Symptom: the widget mounts fine, but the QR code's right finder pattern (top-right) or the bottom alignment pattern inside the iframe looks cut off. The camera can't recognize it, or recognizes it but reads the wrong payload.
Cause: the RQRCode library emits the SVG at native pixel size (module_size × modules + offset). The longer the payload (URL + session UUID, etc.), the more modules there are, so the SVG grows larger than the 200px container. Without overflow:hidden on the container, the SVG spills outside the visible area; with it, the SVG is clipped.
Fix (already applied; regression-prevention note): embed_qr_controller.js#renderQR forces width="100%" height="100%" preserveAspectRatio="xMidYMid meet" on the SVG element. Use the same pattern whenever you add new SVG-based QR render code:
// ✅ Scale while preserving the viewBox ratio to fit the container
svgEl.setAttribute("width", "100%")
svgEl.setAttribute("height", "100%")
svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet")
// ❌ Wrong — using RQRCode's native size as-is → container overflow
container.replaceChildren(svgEl)Why native size is dangerous: when the payload length changes (e.g. a nonce rotation, an added scope), the number of QR modules grows → the SVG width increases → at some point it exceeds 200px → the finder is clipped. Defense-in-depth: always fit the SVG to the container ratio, and control the QR size by the container's own size.
Widget shows "unknown client_id" inside iframe
Symptom: the logi QR page comes up inside the iframe, but it only shows "unknown client_id / one moment… / close" text and the QR itself isn't drawn. The Render log shows POST /oauth/qr/start ... Parameters: {"qr_login" => {}} + Completed 400 Bad Request.
Cause: the iframe page attaches two Stimulus controllers (embed-qr + qr-login) at the same time, and both auto-run POST /oauth/qr/start in connect() — a race. If the view fills in the data-*-value of only one namespace, the other controller POSTs with an empty body → 400.
Fix (server side): attach only embed-qr to the data-controller attribute of app/views/embed/qr/show.html.erb. The single embed-qr controller owns the lifecycle (start → poll → approved → ?embed=1 complete → postMessage), and the qr-login DOM target attributes (data-qr-login-target=...) remain only for use as plain selectors.
<%# ❌ Wrong — qr-login also auto-starts, causing a race + 400 %>
<section data-controller="embed-qr qr-login" ...>
<%# ✅ Correct — embed-qr alone; move all of qr-login's namespace values
into the embed-qr namespace too %>
<section data-controller="embed-qr"
data-embed-qr-oauth-params-value='<%= ... %>'
...>Regression guard: add an inline comment above the controller declaration in app/views/embed/qr/show.html.erb stating "why it's a single attach." If a PR review revives attaching both controllers at once, reject it.
The widget only mounts and shows an empty iframe
Symptom: only a gray box appears where <div data-logi-qr> is, and no QR shows. The console shows a CSP or X-Frame-Options error.
Candidate causes:
- The RP's origin is not registered in
OauthApplication.widget_origins widget_enabled = false- The RP page's CSP
frame-ancestorsblocksembed.1pass.dev
Fix:
# In the Rails console or the [start.1pass.dev/developer](https://start.1pass.dev/developer) console
app = OauthApplication.find_by!(name: "your-rp")
app.update!(
widget_enabled: true,
widget_origins: [
"https://your-app.com", # production
"http://localhost:3000" # dev — exact scheme + port
]
)CSP check:
# Recommended (RP page headers)
Content-Security-Policy: frame-src https://embed.1pass.dev; child-src https://embed.1pass.dev;The widget doesn't work even after calling mountWidget
Symptom: after loading <script src="…/widget.js"> and dynamically adding a <div>, the widget doesn't mount.
Cause: widget.js's auto-init runs only once, on DOMContentLoaded. Mount nodes added after that are not discovered automatically.
Fix: call it directly
const mount = document.getElementById("logi-mount");
// after setting all the data-* attributes
if (window.LogiWidget) window.LogiWidget.mountWidget(mount);Or, for React/Vue/Svelte, see the Widget SDK framework examples.
postMessage doesn't reach the parent
Symptom: the user finishes scanning the QR + approving in the app, but data-on-success isn't called.
Cause: the widget verifies event.origin, but it mismatches the parent page's origin. Or the function data-on-success="…" points to isn't on window.
Fix:
- Confirm the callback function is registered on window —
window.handleLogin = function() {…} - The parent must also verify
event.origin === "https://embed.1pass.dev"— messages from any other origin are ignored (security)
Standard OAuth Flow
401 invalid_client
Symptom: the /oauth/token POST returns 401 + {"error": "invalid_client"}.
Candidate causes:
- The
client_secretisn't picked up from the environment variable (Render: build-time vs runtime separation) - The
client_id/client_secretare values from a different environment (production / staging) - The
client_secretwas rotated and the old value is still in use - (Mobile RP) the build script forgot to inject the client_id → a placeholder string is baked into the app binary. The Flutter
String.fromEnvironmentdefaultValue trap is the classic example. → see the Flutter integration guide
Fix:
# On the RP backend
echo $LOGI_CLIENT_ID # logi_xxxxxxxxxxxx format
echo $LOGI_CLIENT_SECRET | head -c 8 # show only the first 8 chars
# Compare that client_id matches on the RP settings page in the 1pass console
# If it doesn't match, suspect a rotation — Console → app detail → "Reveal current secret"Render environment variable trap
Referencing $LOGI_CLIENT_SECRET in the Build Command yields an empty string — Render's secret environment variables are injected only at runtime. They're usable only in the Start Command, like bundle exec rails s.
302 redirect_uri_mismatch
Symptom: /oauth/authorize does a 302 to an error page.
Cause: the redirect_uri parameter does not match the RP's registered redirect_uris character for character. Common traps:
- trailing slash (
/cbvs/cb/) httpvshttps- subdomain (
app.example.comvswww.app.example.com) - whether a query string is included (
/cb?env=prodvs/cb) — logi does not allow a query string in a registered redirect_uri
Fix: copy the URI registered in the console and use it verbatim.
invalid_request: redirect_uri not registered
Symptom: /oauth/authorize returns a JSON error straight away:
{ "error": "invalid_request", "error_description": "redirect_uri not registered" }Cause: it looks like redirect_uri_mismatch but it's different — mismatch means "there's a similar candidate but the characters differ," while not registered means there's no candidate in the whitelist at all. The most common paths:
- A mobile RP (e.g.
redirect_uris=["app://oauth/1pass/callback"]) starts also exposing a web surface under the sameclient_id→ the web callback (https://app.example.dev/auth/1pass/callback) isn't in the whitelist - A new staging / preview domain was created and the RP update was missed
- An attempt to use a branch preview URL (e.g. Vercel
*-git-feature-x.vercel.app) → violates the static whitelist - An RP-side callback path rename (e.g.
/auth/1pass/callback→/auth/logi/callback) applied only in the RP code, missing the synchronized IdP whitelist update — the most common regression pattern. It's discovered on the first user click right after deploy
Fix:
# 1. Check the RP's current redirect_uris
logi app show $CLIENT_ID
# 2. Add the missing URI (keep existing entries, append)
logi app update $CLIENT_ID --add-redirect-uri "https://app.example.dev/auth/1pass/callback"
# 3. Verify right after adding
logi apps verify $CLIENT_ID -r "https://app.example.dev/auth/1pass/callback"SSH workaround (when the CLI isn't installed and you have direct access to logi-server):
ssh <LOGI_WEB_SERVICE_ID>@<RENDER_SSH_HOST> \
'cd server && bundle exec rails runner "app = OauthApplication.find_by(client_id: %q[logi_xxxxxxxxxxxxxxxxxxxx]); nu = %q[https://app.example.dev/auth/1pass/callback]; app.update!(redirect_uris: (app.redirect_uris + [nu]).uniq) unless app.redirect_uris.include?(nu); puts app.reload.redirect_uris.inspect"'Security
For a public + PKCE RP, sharing the same client_id between mobile and web is safe. For a confidential RP (using a client_secret), separating surfaces is safer — we recommend issuing a separate client_id.
Regression prevention (callback path rename)
A PR that changes the callback URL on the RP side (e.g. /auth/old/callback → /auth/new/callback for a brand integration) must also handle the IdP whitelist update in the same PR. Recommended procedure:
- Keep the old path + add the new path to expand the whitelist first (before deploy)
- Deploy the RP → verify with real traffic
- After a while (once in-flight sessions have expired, typically 7+ days), remove the old path
Verify immediately before deploy:
# Check that the new redirect_uri is in the whitelist (302 response = OK, JSON error = missing)
curl -sI "https://api.1pass.dev/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$NEW_URI_ENCODED&response_type=code&scope=openid&state=preflight&code_challenge=x&code_challenge_method=S256" | head -1Putting the call above in CI catches the omission before it reaches production traffic.
state mismatch / lost session
Symptom: the callback arrives but state verification fails.
Candidate causes:
- The user started login, then started again in another tab (overwriting state)
- The session cookie wasn't sent after the first redirect because it's Lax/Strict (cross-site)
- The RP is stateless (load balancer) without sticky sessions
Fix:
- Keep the
state → verifiermapping as a dict (not a single value) — thesession[:onepass_pkce]pattern in recommended-architecture - Use a session store that can be shared across instances (Redis, DB) —
cookie_storeis fine - iframe integration may require
SameSite=None; Secure
The code has expired
Symptom: invalid_grant at token exchange.
Cause: the code 1pass issues expires after 10 minutes (RFC 6749 §4.1.2 best practice — OauthAccessGrant::EXPIRY = 10.minutes). Or reuse of a code that was already used.
Fix:
- Exchange immediately upon receiving the callback (don't wait for user input)
- On retry, re-issue starting from
state
403 anonymous_not_allowed
Symptom: a mobile-app user approved on the consent screen, but logi returns 403 + {"error": "anonymous_not_allowed"}, and the app shows a message like "Only accounts that have completed email sign-up…".
Cause: the user attempted to consent to an allow_anonymous_grants=false RP while being a logi anonymous account (not linked to Apple/Google, no email registered). This is not a logi problem — it's that RP's policy. External RPs accept only identified subjects by default.
The RP developer's options:
- Accept anonymous too — if your service is suitable for guest mode, toggle
allow_anonymous_grants=truein the RP settings. (How to configure) - Inform the user — use the
application_name+remediationfields in the 403 response to offer a CTA like "This app requires an identified account. Please link Apple/Google or register an email in your account settings." Response shape: Anonymous Grants — block response
The user's fix: logi app → Settings → Account → link Apple / Google, or register an email. After promotion, return to the RP and retry.
A smoother experience (recommended): the 403 response also comes with a promotion object. Using this object, you don't need to throw the user outside the OAuth dance — you can collect Apple/Google/email registration via an inline promotion sheet and continue the same flow with a resume_token. For the full flow and response shape: JIT Promotion.
422 resume_token_expired / resume_token_already_used / promotion_incomplete
Symptom: you finished registration via JIT promotion and called POST /api/v1/oauth/authorize/resume, but got a 422.
| Error | Meaning | Fix |
|---|---|---|
resume_token_expired | The 5-minute TTL exceeded | Start over (retry /oauth/authorize) |
resume_token_already_used | The same token was redeemed twice | Get a new token (retry) |
promotion_incomplete | The token is valid but the user is still anonymous | The registration step didn't finish. Re-check the /api/v1/me/connected_identities or /api/v1/me/emails response |
resume_user_mismatch (403) | The PAK diverged to a different user after the token was issued | Start over |
Device Flow (RFC 8628)
Repeated slow_down error
Symptom: the /oauth/device/poll response keeps returning slow_down.
Cause: the poll interval is too fast (RFC recommendation: 5 seconds).
Fix: follow the Retry-After header in the response or the interval value in the body. It's safe to increase the interval by +5 seconds on each slow_down.
user_code expires too quickly
Symptom: it expires before the user scans the QR.
Cause: 1pass's device code TTL is 10 minutes.
Fix: auto-refresh — issue a new device code as it nears 10 minutes. The user_code entry screen on the logi app side works the same way.
id_token verification
iss mismatch
Symptom: the id_token's iss is https://api.1pass.dev but the RP code compares against https://1pass.dev → failure.
Cause: the production issuer is https://api.1pass.dev. (start.1pass.dev is the console; 1pass.dev is the docs.)
Fix: use the issuer field of .well-known/openid-configuration verbatim.
// Pattern for fetching it dynamically
const config = await fetch("https://api.1pass.dev/.well-known/openid-configuration").then(r => r.json());
expect(idToken.iss).toEqual(config.issuer);Verification fails after JWKS key rotation
Symptom: it worked until yesterday, but suddenly id_token signature verification fails.
Cause: the JWKS key was rotated and the RP is caching the old key.
Fix: keep the JWKS fetch cache to 1 hour or less, and refresh immediately if the kid isn't in the cache. Libraries (jose, jwt) usually handle this for you.
General debugging tools
1. Check the discovery document
curl -s https://api.1pass.dev/.well-known/openid-configuration | jq .Confirm that issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, and embed_endpoint all return a healthy 200.
2. Check the JWKS
curl -s https://api.1pass.dev/.well-known/jwks.json | jq '.keys[].kid'3. Decode the id_token (no signature verification — debugging only)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .4. Hand-build the authorization start URL
echo "https://api.1pass.dev/oauth/authorize?$(cat <<EOF | tr -d ' \n'
response_type=code&
client_id=logi_xxx&
redirect_uri=https%3A%2F%2Fexample.com%2Fcb&
scope=openid+profile%3Abasic+email&
state=test123&
code_challenge=$CHALLENGE&
code_challenge_method=S256
EOF
)"5. The widget demo page
A working production sample: https://api.1pass.dev/widget-demo.html. It mounts against the actual 1pass-demo RP, so it's a reference you can use to confirm the widget works correctly.
If it still doesn't work
- Developer console → app detail → "Recent error logs"
- Information that helps debugging: the
client_id(first 12 chars), the time, theerror+error_description, and the SDK version in use