Skip to Content
We are live but in Staging 🎉
RecipesHosted Login for an SPA

Hosted login for an SPA

The recommended integration: your app never sees a password, tokens never ride the URL, and the sign-in screens are AppID’s problem (branded as yours).

Prereqs: a pool with the local connection, and your callback in settings.redirect_uris (Quickstart steps 1–2).

1. Redirect to /authorize with PKCE

const ISSUER = 'https://appid.dodil.io/acme/my-app' const CLIENT_ID = 'my-web' // your app's name for itself const REDIRECT = 'https://app.example.com/callback' function b64url(bytes) { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } async function login() { const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const challenge = b64url(new Uint8Array(digest)) const state = b64url(crypto.getRandomValues(new Uint8Array(16))) sessionStorage.setItem('pkce_verifier', verifier) sessionStorage.setItem('oauth_state', state) location.href = `${ISSUER}/authorize?` + new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT, state, code_challenge: challenge, code_challenge_method: 'S256', scope: 'openid', }) }

There is no client secret — AppID clients are public and PKCE S256 is mandatory. The user signs in (or signs up, or recovers their password) on the hosted page and comes back to your redirect_uri with ?code=…&state=…&iss=….

2. Exchange the code at your callback

const params = new URLSearchParams(location.search) if (params.get('state') !== sessionStorage.getItem('oauth_state')) throw new Error('state mismatch') if (params.get('error') === 'access_denied' && params.get('error_description') === 'login_session_expired') { login() // the 30-min login window lapsed — just start over } const res = await fetch(`${ISSUER}/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: params.get('code'), redirect_uri: REDIRECT, code_verifier: sessionStorage.getItem('pkce_verifier'), client_id: CLIENT_ID, }), }) const { access_token, refresh_token, id_token, expires_in } = await res.json()

Codes are single-use and expire in 60 seconds — exchange immediately. Because we asked for scope=openid, an id_token arrives too (aud = your client_id).

If your SPA calls {issuer} endpoints directly from the browser (this exchange included), the app’s origin must be in the pool’s policies.cors_allowed_origins.

3. Keep the session fresh

async function refresh(rt) { const r = await fetch(`${ISSUER}/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: rt }), }) if (!r.ok) return login() // family revoked or expired — sign in again return r.json() // ALWAYS replace the stored refresh_token }

Refresh tokens rotate every use — persist the newest one only. Reuse of an old token revokes the whole session (a ~10 s grace window forgives genuine double-submits). Sign out with POST {issuer}/logout { refresh_token }.

4. Smoke-test without a browser

dodil appid pool test-flow my-app [email protected] 'S3curePass!' \ --redirect-uri https://app.example.com/callback

Same flow, headless — run it whenever you change settings.

Branding

The hosted pages (and the verification/reset emails) render from the pool’s branding document:

dodil appid branding set my-app @branding.json
{ "logo_url": "https://cdn.example.com/logo.svg", "background_image_url": "https://cdn.example.com/bg.jpg", "colors": { "primary": "#4f46e5", "background": "#ffffff", "surface": "#f8f8fc", "text": "#111118", "muted": "#667", "error": "#c0392b" }, "dark": { "colors": { "background": "#0b0b12", "surface": "#15151f", "text": "#eee" } }, "radius": "12px", "font_family": "Inter", "font_url": "https://fonts.googleapis.com/css2?family=Inter&display=swap", "layout": "card", "copy": { "title": "Sign in to My App", "signup_title": "Create your account", "subtitle": "Welcome back", "footer": "© Acme" }, "links": { "terms": "https://example.com/terms", "privacy": "https://example.com/privacy", "support": "https://example.com/help" }, "custom_css": ".appid-card { box-shadow: none }", "email": { "from_name": "My App", "logo_url": "https://cdn.example.com/logo.png", "accent": "#4f46e5", "footer": "Acme Inc, 1 Example Street" } }

Every key is optional. layout is card, split, or minimal; image URLs must be https; colors are validated (#hex / rgb() / hsl()); custom_css is size-capped and applied last. Contrast for text on your primary color is computed automatically. Changes go live immediately — the page fetches branding from GET {issuer}/branding on load.

Bringing your own login UI instead? Skip /authorize entirely and use the direct grants from your own screens — signup, token (password/refresh), recover. The branding endpoint hands your UI the pool’s password_min_length so client-side validation matches the server.