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.

OAuth 2.0 OpenID Connect PKCE (S256) RS256 JWT OIDC Discovery
⬇ Download LLM Implementation Guide (.md)
Need identity verification (eKYC)? This page covers OAuth/SSO login only. For document scanning, liveness detection, and KYC verification, see Certula Verify →

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.

Security: 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

sequenceDiagram autonumber participant U as User Browser participant A as Your App participant C as Certula Login participant API as Certula API U->>A: Click "Login with Certula" A->>A: Generate state + PKCE verifier/challenge A->>A: Store state+verifier in session A->>U: Redirect to /oauth/authorize?code_challenge=S256 U->>C: Opens Certula login page C->>API: POST /oauth/flow/check-email API-->>C: {next_step} C->>API: POST /oauth/flow/login (or /register) API-->>C: {authorization_code, expires_in:600} C->>U: Redirect to your redirect_uri?code=...&state=... U->>A: Callback received A->>A: Validate state == session.state (CSRF check) A->>API: POST /oauth/token (server-side, client_secret+code_verifier) API-->>A: {access_token, refresh_token, id_token, expires_in:900} A->>API: GET /oauth/userinfo (Bearer access_token) API-->>A: {sub, email, name, kyc_verified, ...} A->>U: Session created, user logged in

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.

CredentialExampleUse
client_idca_myapp_Nno9wDizs3zn…Public — safe in frontend authorize URLs
client_secretcs_…⚠ Server-side only
webhook_secret(optional)For HMAC webhook verification
redirect_uris
string[]
Exact URIs allowed. No wildcards. Add dev, staging, and prod separately.
allowed_scopes
string[]
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

GEThttps://api.certula.com/oauth/authorize
Redirects user to Certula login. Build this URL on either frontend or backend — it contains no secrets.
response_type required
string
Always code.
client_id required
string
Your app's client ID from the dashboard.
redirect_uri required
string
Must exactly match a registered URI. URL-encoded.
scope required
string
Space-separated. Must include openid. Example: openid profile email.
state required
string
Random unguessable string. Store in session; verify on callback to prevent CSRF.
code_challenge required
string
Base64url(SHA-256(code_verifier)).
code_challenge_method required
string
Must be 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.

POSThttps://api.certula.com/oauth/token
Content-Type: application/x-www-form-urlencoded
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:    '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
FieldTypeDescription
access_tokenstringRS256 JWT. Use as Bearer. Expires 900 s.
refresh_tokenstringOpaque. Expires 7 d (30 d with remember_me). Rotated on use.
id_tokenstringOIDC identity JWT. Contains user claims.
expires_ininteger900 seconds.
token_typestring"bearer"

User Info Endpoint

GEThttps://api.certula.com/oauth/userinfo
Requires 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
}
*/
ClaimScope RequiredDescription
subopenidStable user ID. Use as primary key in your DB.
emailemailPrimary email address.
email_verifiedemailBoolean — Certula-verified.
nameprofileFull display name.
phonephoneE.164 format.
phone_verifiedphoneBoolean — OTP-verified.
kyc_verifiedkycTrue only after successful eKYC.
pictureprofileAvatar URL (optional).
Tip: Always use sub as the foreign key. Email can change; sub is permanent for the lifetime of the account.

Token Refresh

POSThttps://api.certula.com/oauth/token
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
Token Rotation: Certula rotates refresh tokens on each use. Always persist the new refresh_token from the response immediately.

Token Revocation

POSThttps://api.certula.com/oauth/revoke
// 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 }
}
Mobile: Never embed 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

ScopeClaimsNotes
openidsub, iat, expRequired for all flows.
profilename, pictureDisplay name and avatar.
emailemail, email_verifiedPrimary email address.
phonephone, phone_verifiedE.164 phone, OTP-verified status.
kyckyc_verifiedTrue only after completed Certula eKYC. App must have kyc scope enabled.

Token Lifetimes

TokenLifetimeNotes
Authorization Code10 minSingle use. Exchange immediately.
Access Token15 min (900 s)RS256 JWT. Verify signature with JWKS.
Refresh Token7 daysRotated on each use.
Refresh Token (remember_me)30 daysUser checked "remember me".
OTP Code10 minEmbedded auth flow only.

JWKS & JWT Verification

GEThttps://api.certula.com/oauth/.well-known/jwks.json
// 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,
    )
Performance: jose (Node.js) caches JWKS automatically. In Python, the lru_cache approach avoids a remote call on every request.

OIDC Discovery

GEThttps://api.certula.com/oauth/.well-known/openid-configuration
Standard OIDC discovery document for auto-configuration of OIDC-compatible libraries.
{
  "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.

POST/oauth/flow/check-email
Step 1 — determine the next action for an email address.
{ "email": "user@example.com", "client_id": "ca_..." }
// → { "next_step": "password" | "otp_login" | "register" }
POST/oauth/flow/login
Step 2a — login with password. Returns an authorization_code (10 min) on success.
{ "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 }
POST/oauth/flow/otp-login
Step 2b — login with email OTP. Call /flow/send-otp first.
{ "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 }
POST/oauth/flow/register
Register a new user and immediately obtain an authorization_code.
{ "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

EndpointPurpose
POST /oauth/flow/send-otpSend email OTP. Body: {"email","client_id"}
POST /oauth/flow/resend-otpResend email OTP.
POST /oauth/flow/verify-otpVerify OTP without issuing code. Body: {"email","otp"}
POST /oauth/flow/send-phone-otpSend SMS OTP. Body: {"phone","client_id"}
POST /oauth/flow/verify-phone-otpVerify SMS OTP.
POST /oauth/flow/forgot-passwordSend password reset email.
POST /oauth/flow/validate-reset-tokenValidate reset token from email.
POST /oauth/flow/reset-passwordSet new password with valid reset token.

Two-Factor Authentication Coming Soon

🔑
2FA is not yet available

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.

MethodStatus
TOTP (Google Authenticator, Authy, 1Password)Coming Soon
WebAuthn / PasskeysComing Soon
SMS OTP as second factorComing 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

errorHTTPCause
invalid_client401Wrong client_id or client_secret.
invalid_grant400Code used or expired, or code_verifier mismatch.
invalid_request400Missing or malformed parameter.
invalid_scope400Scope not allowed for this client.
redirect_uri_mismatch400URI not in registered list.
access_deniedUser cancelled. Arrives as URL query param.
server_error500Unexpected error. Retry with exponential backoff.
{ "error": "invalid_grant", "error_description": "Authorization code has expired" }