Certula Connect
Add "Login with Certula" to your application. Users authenticate with their Certula account and you receive an OIDC identity token with their verified profile — without handling passwords or storing credentials.
⬇ Download LLM Implementation Guide (.md)Quick Start
1 — Get a Connected App
Register at certula.com/dashboard → Connected Apps. You receive a client_id and client_secret. Configure allowed redirect URIs.
client_secret is for server-to-server use only. Never expose it in frontend code, mobile apps, or public repos.2 — Redirect to Certula
// Generate on every login attempt const state = crypto.randomUUID() const codeVerifier = generateCodeVerifier() // 43-128 random chars, base64url const codeChallenge = await sha256Base64url(codeVerifier) // Store in session BEFORE redirect session.state = state session.codeVerifier = codeVerifier const url = new URL('https://api.certula.com/oauth/authorize') url.searchParams.set('response_type', 'code') url.searchParams.set('client_id', process.env.CERTULA_CLIENT_ID) url.searchParams.set('redirect_uri', 'https://yourapp.com/auth/callback') url.searchParams.set('scope', 'openid profile email') url.searchParams.set('state', state) url.searchParams.set('code_challenge', codeChallenge) url.searchParams.set('code_challenge_method', 'S256') res.redirect(url.toString())
3 — Exchange Code for Tokens (server-side)
// GET /auth/callback?code=xxx&state=yyy const { code, state } = req.query if (state !== req.session.state) return res.status(400).send('Invalid state') const tokenRes = await fetch('https://api.certula.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: 'https://yourapp.com/auth/callback', client_id: process.env.CERTULA_CLIENT_ID, client_secret: process.env.CERTULA_CLIENT_SECRET, // server-side only! code_verifier: req.session.codeVerifier, }) }) const tokens = await tokenRes.json()
4 — Fetch the User
const user = await fetch('https://api.certula.com/oauth/userinfo', { headers: { 'Authorization': `Bearer ${tokens.access_token}` } }).then(r => r.json()) // { sub, email, name, phone, kyc_verified, ... }
Complete Auth Flow Diagram
Auth code: 10 min. Access token: 15 min. Refresh token: 7 days (30 days with remember_me). The client_secret never leaves your server.
Connected App Setup
Create a Connected App at certula.com/dashboard → Connected Apps → New App.
| Credential | Example | Use |
|---|---|---|
client_id | ca_myapp_Nno9wDizs3zn… | Public — safe in frontend authorize URLs |
client_secret | cs_… | ⚠ Server-side only |
webhook_secret | (optional) | For HMAC webhook verification |
redirect_urisstring[]
allowed_scopesstring[]
openid, profile, email, phone, kyc. Request only what you need.PKCE Implementation
PKCE is mandatory. Only S256 is accepted — plain method is rejected.
// TypeScript / Browser (Web Crypto API) function generateCodeVerifier(): string { const arr = new Uint8Array(32) crypto.getRandomValues(arr) return btoa(String.fromCharCode(...arr)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } async function generateCodeChallenge(verifier: string): Promise<string> { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) return btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } const verifier = generateCodeVerifier() const challenge = await generateCodeChallenge(verifier) // Store verifier in sessionStorage; send challenge in authorize URL
// Node.js (crypto module — no dependencies) const crypto = require('crypto') function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url') // Node 16+ } function generateCodeChallenge(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url') } const verifier = generateCodeVerifier() const challenge = generateCodeChallenge(verifier)
# Python (stdlib only) import secrets, hashlib, base64 def generate_code_verifier() -> str: return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode() def generate_code_challenge(verifier: str) -> str: digest = hashlib.sha256(verifier.encode()).digest() return base64.urlsafe_b64encode(digest).rstrip(b'=').decode() verifier = generate_code_verifier() challenge = generate_code_challenge(verifier)
Authorization Endpoint
response_type requiredstring
code.client_id requiredstring
redirect_uri requiredstring
scope requiredstring
openid. Example: openid profile email.state requiredstring
code_challenge requiredstring
code_challenge_method requiredstring
S256. plain is rejected.Callback Response
# Success https://yourapp.com/auth/callback?code=AUTH_CODE&state=YOUR_STATE # Error https://yourapp.com/auth/callback?error=access_denied&error_description=User+denied+access
Token Exchange
Exchange authorization code for tokens. Must happen server-side.
application/x-www-form-urlencodedconst res = await fetch('https://api.certula.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: req.query.code, redirect_uri: 'https://yourapp.com/auth/callback', client_id: process.env.CERTULA_CLIENT_ID, client_secret: process.env.CERTULA_CLIENT_SECRET, code_verifier: req.session.codeVerifier, }) }) if (!res.ok) { const e = await res.json(); throw new Error(e.error_description) } const { access_token, refresh_token, id_token, expires_in } = await res.json() // access_token: RS256 JWT 15min | refresh_token: 7d | id_token: OIDC identity
import httpx async with httpx.AsyncClient() as client: r = await client.post( "https://api.certula.com/oauth/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": settings.certula_redirect_uri, "client_id": settings.certula_client_id, "client_secret": settings.certula_client_secret, "code_verifier": session.code_verifier, }, ) r.raise_for_status() tokens = r.json()
curl -X POST https://api.certula.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=AUTH_CODE \ -d redirect_uri=https://yourapp.com/auth/callback \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d code_verifier=YOUR_CODE_VERIFIER
| Field | Type | Description |
|---|---|---|
access_token | string | RS256 JWT. Use as Bearer. Expires 900 s. |
refresh_token | string | Opaque. Expires 7 d (30 d with remember_me). Rotated on use. |
id_token | string | OIDC identity JWT. Contains user claims. |
expires_in | integer | 900 seconds. |
token_type | string | "bearer" |
User Info Endpoint
Authorization: Bearer {access_token}. Claims depend on scopes requested.// Node.js const user = await fetch('https://api.certula.com/oauth/userinfo', { headers: { 'Authorization': `Bearer ${accessToken}` } }).then(r => r.json()) /* { sub: "user_abc123", // Use as foreign key — stable, never changes email: "user@example.com", email_verified: true, name: "Ahmad bin Razif", phone: "+60123456789", phone_verified: true, kyc_verified: true, // requires 'kyc' scope + completed eKYC picture: "https://..." // optional } */
| Claim | Scope Required | Description |
|---|---|---|
sub | openid | Stable user ID. Use as primary key in your DB. |
email | Primary email address. | |
email_verified | Boolean — Certula-verified. | |
name | profile | Full display name. |
phone | phone | E.164 format. |
phone_verified | phone | Boolean — OTP-verified. |
kyc_verified | kyc | True only after successful eKYC. |
picture | profile | Avatar URL (optional). |
sub as the foreign key. Email can change; sub is permanent for the lifetime of the account.Token Refresh
const res = await fetch('https://api.certula.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: storedRefreshToken, client_id: process.env.CERTULA_CLIENT_ID, client_secret: process.env.CERTULA_CLIENT_SECRET, }) }) if (!res.ok) return redirectToLogin() // expired or revoked const { access_token, refresh_token } = await res.json() // Save the NEW refresh_token — old one is invalidated (rotation)
async with httpx.AsyncClient() as client: r = await client.post("https://api.certula.com/oauth/token", data={ "grant_type": "refresh_token", "refresh_token": stored_refresh_token, "client_id": settings.certula_client_id, "client_secret": settings.certula_client_secret, }) if r.status_code == 401: raise HTTPException(401) # force re-login tokens = r.json() # Always persist the new refresh_token — old one is now rotated out
refresh_token from the response immediately.Token Revocation
// Node.js logout await fetch('https://api.certula.com/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: req.session.refreshToken, client_id: process.env.CERTULA_CLIENT_ID, client_secret: process.env.CERTULA_CLIENT_SECRET, }) }) req.session.destroy() res.redirect('/')
React / Next.js Integration
Login Button Component
"use client" // components/LoginWithCertula.tsx import { useCallback } from 'react' async function sha256b64url(str: string) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)) return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'') } export function LoginWithCertula() { const handleLogin = useCallback(async () => { const arr = crypto.getRandomValues(new Uint8Array(32)) const verifier = btoa(arr.join(',')).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'') const challenge = await sha256b64url(verifier) const state = crypto.randomUUID() sessionStorage.setItem('certula_verifier', verifier) sessionStorage.setItem('certula_state', state) const p = new URLSearchParams({ response_type: 'code', client_id: process.env.NEXT_PUBLIC_CERTULA_CLIENT_ID!, redirect_uri: `${window.location.origin}/auth/callback`, scope: 'openid profile email', state, code_challenge: challenge, code_challenge_method: 'S256', }) window.location.href = `https://api.certula.com/oauth/authorize?${p}` }, []) return <button onClick={handleLogin}>Continue with Certula</button> }
Next.js 15 Route Handler — Callback
// app/auth/callback/route.ts import { NextRequest, NextResponse } from 'next/server' import { cookies } from 'next/headers' export async function GET(req: NextRequest) { const code = req.nextUrl.searchParams.get('code')! const state = req.nextUrl.searchParams.get('state') if (req.nextUrl.searchParams.get('error')) return NextResponse.redirect(new URL('/login?error=access_denied', req.url)) const cookieStore = await cookies() if (state !== cookieStore.get('certula_state')?.value) return NextResponse.redirect(new URL('/login?error=invalid_state', req.url)) const tokenRes = await fetch('https://api.certula.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`, client_id: process.env.CERTULA_CLIENT_ID!, client_secret: process.env.CERTULA_CLIENT_SECRET!, code_verifier: cookieStore.get('certula_verifier')!.value, }) }) if (!tokenRes.ok) return NextResponse.redirect(new URL('/login?error=token_failed', req.url)) const { access_token } = await tokenRes.json() const user = await fetch('https://api.certula.com/oauth/userinfo', { headers: { Authorization: `Bearer ${access_token}` } }).then(r => r.json()) const response = NextResponse.redirect(new URL('/dashboard', req.url)) response.cookies.set('user_sub', user.sub, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 }) return response }
React Native / Expo Integration
// npm: npx expo install expo-auth-session expo-web-browser expo-crypto import * as AuthSession from 'expo-auth-session' import * as WebBrowser from 'expo-web-browser' WebBrowser.maybeCompleteAuthSession() const discovery = { authorizationEndpoint: 'https://api.certula.com/oauth/authorize', tokenEndpoint: 'https://api.certula.com/oauth/token', } export function useCertulaAuth() { const redirectUri = AuthSession.makeRedirectUri({ scheme: 'myapp' }) const [request,, promptAsync] = AuthSession.useAuthRequest({ clientId: 'YOUR_CLIENT_ID', // public — safe in app redirectUri, scopes: ['openid', 'profile', 'email'], usePKCE: true, }, discovery) const login = async () => { const result = await promptAsync() if (result.type === 'success') { // ⚠ Route code+verifier to YOUR backend for exchange — never embed client_secret await fetch('https://yourapi.com/auth/certula/exchange', { method: 'POST', body: JSON.stringify({ code: result.params.code, codeVerifier: request?.codeVerifier, redirectUri }), }) } } return { login } }
client_secret in a mobile app. Token exchange must go through your backend.Vanilla JS / SPA
async function loginWithCertula() { const arr = new Uint8Array(32) crypto.getRandomValues(arr) const verifier = btoa(arr.join('')).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'') const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const challenge = btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'') const state = crypto.randomUUID() sessionStorage.setItem('cv', verifier) sessionStorage.setItem('st', state) const u = new URL('https://api.certula.com/oauth/authorize') Object.entries({response_type:'code',client_id:'YOUR_CLIENT_ID',redirect_uri:location.origin+'/callback', scope:'openid profile email',state,code_challenge:challenge,code_challenge_method:'S256' }).forEach(([k,v])=>u.searchParams.set(k,v)) location.href = u.toString() } // On /callback page: const p = new URLSearchParams(location.search) if (p.get('state') !== sessionStorage.getItem('st')) throw new Error('CSRF') // POST { code, codeVerifier: sessionStorage.getItem('cv') } to your backend
Node.js (Express) — Complete Backend
// routes/certula.js const express = require('express'), crypto = require('crypto') const router = express.Router() const BASE = 'https://certula.com' const { CERTULA_CLIENT_ID: CLIENT, CERTULA_CLIENT_SECRET: SECRET, APP_URL } = process.env const REDIRECT = `${APP_URL}/auth/callback` router.get('/login', (req, res) => { const verifier = crypto.randomBytes(32).toString('base64url') const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') const state = crypto.randomUUID() req.session.certulaVerifier = verifier req.session.certulaState = state const url = new URL(`${BASE}/oauth/authorize`) Object.entries({ response_type:'code', client_id:CLIENT, redirect_uri:REDIRECT, scope:'openid profile email', state, code_challenge:challenge, code_challenge_method:'S256' }).forEach(([k,v])=>url.searchParams.set(k,v)) res.redirect(url.toString()) }) router.get('/callback', async (req, res) => { const { code, state, error } = req.query if (error) return res.redirect(`/login?error=${error}`) if (state !== req.session.certulaState) return res.status(400).send('Bad state') const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type:'authorization_code', code, redirect_uri:REDIRECT, client_id:CLIENT, client_secret:SECRET, code_verifier:req.session.certulaVerifier, }) }) if (!tokenRes.ok) return res.redirect('/login?error=token_failed') const { access_token, refresh_token } = await tokenRes.json() const user = await fetch(`${BASE}/oauth/userinfo`, { headers: { Authorization: `Bearer ${access_token}` } }).then(r => r.json()) req.session.userId = user.sub req.session.accessToken = access_token req.session.refreshToken = refresh_token res.redirect('/dashboard') }) router.post('/logout', async (req, res) => { if (req.session.refreshToken) { await fetch(`${BASE}/oauth/revoke`, { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: new URLSearchParams({token:req.session.refreshToken,client_id:CLIENT,client_secret:SECRET}) }).catch(console.error) } req.session.destroy() res.redirect('/') }) module.exports = router
Node.js (Next.js API Routes)
// app/api/auth/login/route.ts — initiates the OAuth flow import { NextResponse } from 'next/server' import { cookies } from 'next/headers' import crypto from 'crypto' export async function GET() { const verifier = crypto.randomBytes(32).toString('base64url') const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') const state = crypto.randomUUID() const jar = await cookies() jar.set('cv', verifier, { httpOnly:true, secure:true, maxAge:600, sameSite:'lax' }) jar.set('st', state, { httpOnly:true, secure:true, maxAge:600, sameSite:'lax' }) const url = new URL('https://api.certula.com/oauth/authorize') Object.entries({ response_type:'code', client_id:process.env.CERTULA_CLIENT_ID!, redirect_uri:`${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`, scope:'openid profile email', state, code_challenge:challenge, code_challenge_method:'S256' }).forEach(([k,v])=>url.searchParams.set(k,v)) return NextResponse.redirect(url) }
Python (FastAPI) — Complete Backend
# routers/certula_auth.py import secrets, hashlib, base64, httpx, urllib.parse from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse router = APIRouter(prefix="/auth") CERTULA_BASE = "https://certula.com" def pkce_pair(): verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode() challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode() return verifier, challenge @router.get("/certula/login") async def login(request: Request): verifier, challenge = pkce_pair() state = secrets.token_urlsafe(16) request.session["certula_verifier"] = verifier request.session["certula_state"] = state params = { "response_type":"code", "client_id":settings.certula_client_id, "redirect_uri":settings.certula_redirect_uri, "scope":"openid profile email kyc", "state":state, "code_challenge":challenge, "code_challenge_method":"S256", } return RedirectResponse(f"{CERTULA_BASE}/oauth/authorize?{urllib.parse.urlencode(params)}") @router.get("/certula/callback") async def callback(request: Request, code: str, state: str): if state != request.session.get("certula_state"): raise HTTPException(400, "Invalid state") async with httpx.AsyncClient() as c: tok = await c.post(f"{CERTULA_BASE}/oauth/token", data={ "grant_type":"authorization_code", "code":code, "redirect_uri":settings.certula_redirect_uri, "client_id":settings.certula_client_id, "client_secret":settings.certula_client_secret, "code_verifier":request.session["certula_verifier"], }) tok.raise_for_status() user = (await c.get(f"{CERTULA_BASE}/oauth/userinfo", headers={"Authorization":f"Bearer {tok.json()['access_token']}"})).json() request.session["user_id"] = user["sub"] return RedirectResponse("/dashboard")
Scopes
| Scope | Claims | Notes |
|---|---|---|
openid | sub, iat, exp | Required for all flows. |
profile | name, picture | Display name and avatar. |
email | email, email_verified | Primary email address. |
phone | phone, phone_verified | E.164 phone, OTP-verified status. |
kyc | kyc_verified | True only after completed Certula eKYC. App must have kyc scope enabled. |
Token Lifetimes
| Token | Lifetime | Notes |
|---|---|---|
| Authorization Code | 10 min | Single use. Exchange immediately. |
| Access Token | 15 min (900 s) | RS256 JWT. Verify signature with JWKS. |
| Refresh Token | 7 days | Rotated on each use. |
| Refresh Token (remember_me) | 30 days | User checked "remember me". |
| OTP Code | 10 min | Embedded auth flow only. |
JWKS & JWT Verification
// npm install jose import { createRemoteJWKSet, jwtVerify } from 'jose' const JWKS = createRemoteJWKSet( new URL('https://api.certula.com/oauth/.well-known/jwks.json') ) export async function verifyCertulaToken(token: string) { const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://certula.com', audience: process.env.CERTULA_CLIENT_ID, }) return payload // { sub, email, kyc_verified, exp, ... } } // Express middleware: export async function requireAuth(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', '') if (!token) return res.status(401).json({ error: 'Unauthorized' }) try { req.user = await verifyCertulaToken(token); next() } catch { res.status(401).json({ error: 'Invalid or expired token' }) } }
# pip install PyJWT[crypto] import jwt from functools import lru_cache @lru_cache(maxsize=1) def _jwks_client(): return jwt.PyJWKClient("https://api.certula.com/oauth/.well-known/jwks.json") def verify_certula_token(token: str) -> dict: signing_key = _jwks_client().get_signing_key_from_jwt(token) return jwt.decode( token, signing_key.key, algorithms=["RS256"], issuer="https://certula.com", audience=settings.certula_client_id, )
jose (Node.js) caches JWKS automatically. In Python, the lru_cache approach avoids a remote call on every request.OIDC Discovery
{
"issuer": "https://certula.com",
"authorization_endpoint": "https://api.certula.com/oauth/authorize",
"token_endpoint": "https://api.certula.com/oauth/token",
"userinfo_endpoint": "https://api.certula.com/oauth/userinfo",
"revocation_endpoint": "https://api.certula.com/oauth/revoke",
"jwks_uri": "https://api.certula.com/oauth/.well-known/jwks.json",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid","profile","email","phone","kyc"],
"code_challenge_methods_supported": ["S256"]
}
Embedded Auth Flow API
Build a branded login experience inside your app without redirecting to certula.com. These endpoints power Certula's own login UI and are available to Connected Apps.
{ "email": "user@example.com", "client_id": "ca_..." }
// → { "next_step": "password" | "otp_login" | "register" }
{ "email":"...", "password":"...", "client_id":"ca_...",
"code_challenge":"...", "code_challenge_method":"S256",
"scope":"openid profile email", "redirect_uri":"...", "remember_me":false }
// → { "authorization_code": "AC_CODE", "expires_in": 600 }
{ "email":"...", "otp":"123456", "client_id":"ca_...",
"code_challenge":"...", "code_challenge_method":"S256",
"scope":"openid profile email", "redirect_uri":"..." }
// → { "authorization_code": "AC_CODE", "expires_in": 600 }
{ "email":"new@example.com", "password":"...", "name":"Siti Nurhaliza",
"client_id":"ca_...", "code_challenge":"...", "code_challenge_method":"S256",
"scope":"openid profile email", "redirect_uri":"..." }
OTP & Password Reset
| Endpoint | Purpose |
|---|---|
POST /oauth/flow/send-otp | Send email OTP. Body: {"email","client_id"} |
POST /oauth/flow/resend-otp | Resend email OTP. |
POST /oauth/flow/verify-otp | Verify OTP without issuing code. Body: {"email","otp"} |
POST /oauth/flow/send-phone-otp | Send SMS OTP. Body: {"phone","client_id"} |
POST /oauth/flow/verify-phone-otp | Verify SMS OTP. |
POST /oauth/flow/forgot-password | Send password reset email. |
POST /oauth/flow/validate-reset-token | Validate reset token from email. |
POST /oauth/flow/reset-password | Set new password with valid reset token. |
Two-Factor Authentication Coming Soon
TOTP (authenticator app) and WebAuthn / passkey support are in development and not yet released. When available, 2FA will be configurable per user and enforceable per Connected App. Subscribe to updates at certula.com/changelog.
| Method | Status |
|---|---|
| TOTP (Google Authenticator, Authy, 1Password) | Coming Soon |
| WebAuthn / Passkeys | Coming Soon |
| SMS OTP as second factor | Coming Soon |
Note: SMS OTP as a primary login method (passwordless) is available today via /oauth/flow/otp-login. The 2FA milestone refers to a second factor layered on top of password login.
Error Reference
| error | HTTP | Cause |
|---|---|---|
invalid_client | 401 | Wrong client_id or client_secret. |
invalid_grant | 400 | Code used or expired, or code_verifier mismatch. |
invalid_request | 400 | Missing or malformed parameter. |
invalid_scope | 400 | Scope not allowed for this client. |
redirect_uri_mismatch | 400 | URI not in registered list. |
access_denied | — | User cancelled. Arrives as URL query param. |
server_error | 500 | Unexpected error. Retry with exponential backoff. |
{ "error": "invalid_grant", "error_description": "Authorization code has expired" }