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 authorization_request_expired
Symptom: GET /api/v1/oauth/authorize/preview or POST /api/v1/oauth/authorize returns 422 with {"error": "authorization_request_expired", "restart_consent": true}.
Cause: the authorization request is too old. logi measures its age from the moment the server first observed it and rejects both preview and grant past the threshold (10 minutes by default). This stops a consent screen left open in the app from coming back to life and issuing a code to an RP the user did not just start.
Fix: don't resend the same parameters — start the authorize flow again from the RP. Issuing a fresh state and code_challenge makes it a new request. restart_consent: true is the signal for that, so an app receiving this response should close the consent screen and send the user back to the RP entry point.
Nothing is required of the RP — the check runs on request parameters alone, so you don't need to send a new field, and apps built before this check still work unchanged.
During anonymous promotion
Apple/Google two-step sign-in can take longer than 10 minutes. The server extends the window for a promotion round trip, so a normal flow won't be cut off by this error. If it still is, verify the promotion actually completed (403 anonymous_not_allowed → registration finished).
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.
Mobile SDKs (Swift / Android)
First tap fails with notConfigured, second tap works
Symptom: after a cold start (app fully killed, then relaunched), the first tap on "Continue with logi" fails immediately with .notConfigured ("LogiAuth.configure(_😃 was not called") — the logi app never even opens. Tapping the same button again works. It looks like a flaky bug, but it actually reproduces deterministically on every first tap after a cold start.
Cause: calling configure(_:) lazily — at sign-in-button-tap time instead of at app start. That breaks in two distinct ways:
- Same-tick race (Swift SDK ≤ v1.2.x) — older
configureapplied the config asynchronously viaTask { @MainActor }. Whenconfigure → signInrun in the same MainActor tick,signInreads the config before it is applied and throws.notConfigured. The second tap succeeds because the first tap's queued assignment has landed by then — that's where the "only the first tap fails" fingerprint comes from. (Current SDKs apply the config synchronously, closing this path.) - Callback-only cold start — iOS can wake your process with just the OAuth callback URL (app-to-app return, process regeneration). That process reaches
LogiAuth.handle(_:)without any button ever being tapped. The in-flight sign-in died with the old process, so this callback can't be resumed anyway (the right response is a retry — sign-ins don't survive app termination), but if configure hasn't run either, the SDK can't even match the redirect_uri: it silently ignores the callback (no error), and the URL may fall through to your app's other URL handlers. This path exists regardless of SDK version.
Fix: call LogiAuth.configure(_:) once, in your @main App's init(). Any other placement (button handler, view onAppear, lazy init) hits one of the two paths above.
@main
struct MyApp: App {
init() {
LogiAuth.configure(LogiAuthConfig(clientId: "logi_…", redirectURI: …))
}
}If you're stuck on an older SDK (≤ v1.2.x, async configure), a stopgap is to enqueue an empty MainActor task right before signIn() — await Task { @MainActor in }.value — hoping it runs after the queued config assignment. Swift does not contractually order independent unstructured tasks, so treat this as a best-effort mitigation — updating the SDK and placing configure in App.init is the real fix. (Confirmed on-device in ainote, 2026-08-12 — reproduced in two consecutive builds at the same spot before this entry was written.)
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