Recommended architecture (RP integration)
A confidential client + PKCE setup common to the Widget SDK, Device Flow, and Authorization Code Flow. The RP backend holds the client_secret and the code_verifier.
SPA standalone
This is also possible without a backend, as a public client (PKCE-only). The verifier lives in sessionStorage / Web Crypto. For the trade-offs, see Public vs Confidential.
Flow diagram
Browser (RP page) RP backend 1pass IdP
───────────────────── ─────────── ─────────
1) [Click the login button]
─POST /api/auth/1pass/challenge──▶
├─ generate verifier(32B)
├─ challenge = sha256(verifier)
├─ state = random(32B)
├─ session[state] = verifier
◀──{state, code_challenge}─
2) [Universal Link / Widget mount]
─→ authorize?response_type=code ──────────────────────────────▶
client_id, redirect_uri,
state, code_challenge,
code_challenge_method=S256
◀── redirect_uri?code=…&state=─
3) [Browser → /api/auth/1pass/callback]
─GET /api/auth/1pass/callback?code=…──▶
├─ verify state
├─ verifier = session.delete
├─ POST /oauth/token ──────────▶
│ grant_type=authorization_code
│ client_id+client_secret
│ code, code_verifier
│ ◀──{access_token, id_token}──
├─ verify id_token (JWKS)
├─ map sub → your own user model
├─ Set-Cookie: session=…
◀── 302 → /dashboard
4) [Enter /dashboard]The three backend endpoints
1. POST /api/auth/1pass/challenge — issue a PKCE challenge
The code_verifier is stored only in the server session. It must not be sent down to the browser.
# Rails (app/controllers/api/auth/one_pass_controller.rb)
class Api::Auth::OnePassController < ApplicationController
protect_from_forgery with: :null_session
def challenge
state = SecureRandom.urlsafe_base64(32)
verifier = SecureRandom.urlsafe_base64(64).tr("=", "").first(128)
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
session[:onepass_pkce] ||= {}
session[:onepass_pkce][state] = { verifier: verifier, created_at: Time.current.to_i }
# Keep only the most recent 5 (prevents session bloat)
if session[:onepass_pkce].size > 5
session[:onepass_pkce] = session[:onepass_pkce]
.sort_by { |_, v| -v[:created_at] }.first(5).to_h
end
render json: { state: state, code_challenge: challenge }
end
end// Next.js App Router (app/api/auth/1pass/challenge/route.ts)
import { randomBytes, createHash } from "crypto";
export async function POST() {
const state = randomBytes(32).toString("base64url");
const verifier = randomBytes(64).toString("base64url").slice(0, 128);
const challenge = createHash("sha256").update(verifier).digest("base64url");
const session = await getOrCreateSession();
session.onepassPkce = { ...session.onepassPkce, [state]: { verifier, createdAt: Date.now() } };
await session.save();
return Response.json({ state, code_challenge: challenge });
}2. GET /api/auth/1pass/callback — exchange the authorization code
- Verify
state(CSRF) - Pull
verifierfrom the session and delete it (single-use) - Exchange at
/oauth/token(confidential client + PKCE) - Verify the
id_tokenagainst JWKS - Issue your own session cookie + redirect
def callback
return error!("missing_params") if params[:code].blank? || params[:state].blank?
pkce = (session[:onepass_pkce] || {}).delete(params[:state])
return error!("invalid_state") unless pkce
return error!("verifier_expired") if Time.current.to_i - pkce[:created_at] > 600
tokens = OnePassClient.exchange_code(
code: params[:code],
redirect_uri: api_auth_one_pass_callback_url,
code_verifier: pkce[:verifier]
)
id_token_payload = OnePassClient.verify_id_token!(tokens["id_token"])
user = User.find_or_create_by!(onepass_sub: id_token_payload["sub"]) do |u|
u.email = id_token_payload["email"]
end
start_session_for(user)
redirect_to params[:return_to].presence || dashboard_path
end3. (Optional) POST /api/auth/1pass/exchange — Widget SDK only
The widget passes { code, state } to the parent via postMessage. The parent POSTs it as a JSON body → token exchange. It is essentially the same as callback; only the input location differs.
INFO
You can combine #2 and #3 into one controller. Branch on whether code is in the params → redirect, or in the body → JSON response.
Sessions / cookies
| Item | Recommended value | Why |
|---|---|---|
code_verifier storage | encrypted server session | PKCE is void if exposed to the browser |
code_verifier TTL | 10 minutes | Same as the 1pass code expiry (OauthAccessGrant::EXPIRY) |
state verification | secure_compare (timing-safe) | == is exposed to timing attacks |
| Your own session cookie | Secure; HttpOnly; SameSite=Lax | Basic XSS / CSRF defense |
| When using the widget | SameSite=None; Secure | 3rd-party iframe cookie |
CSRF token vs OAuth state
Keep them separate. Do not reuse the CSRF token as the state — that would expose the CSRF token over a single OAuth flow.
- CSRF token: protects form submissions within the same RP
- OAuth state: a nonce that protects the redirect round-trip, bound together with the session's challenge
Minimize scopes
# ❌ Too much
scope = "openid profile:basic email phone birthdate"
# ✅ Recommended
scope = "openid profile:basic email"profile:basic includes name/picture. Get additional claims via a progressive profile. Scope Reference.
Production checklist
- [ ]
redirect_uriis HTTPS (production), with an exact trailing slash - [ ]
client_secretis an environment variable (not in git/the bundle) - [ ]
code_verifieris in the server session only (not exposed in browser DevTools) - [ ]
stateverification (secure_compare) + a 10-minute expiry - [ ]
id_tokenverification: JWKS signature +iss == https://api.1pass.dev+aud == client_id+expin the future - [ ] When using the widget, the exact origin (scheme + host + port) is registered in
widget_origins - [ ] CSP
frame-ancestorsdoes not block the widget (embed.1pass.dev) - [ ] Error pages for token exchange failure / expiry / state mismatch
- [ ] On logout, your own session + (if needed)
/oauth/revoke - [ ] Handle the Webhook backchannel logout