Documentation

Developer reference

Integrate ID identity verification into your application using standard OAuth 2.0 + PKCE and OIDC protocols.

Overview

ID is an OAuth 2.0 + PKCE authorization server with OIDC-compatible userinfo claims. Users have a UCID (Unique Citizen Identifier) and a verification level (ID0–ID5). Apps request only the scopes they need. Users explicitly consent to each scope.

Base URL: https://id.pajic.it

Discovery: GET https://id.pajic.it/api/id/.well-known/openid-configuration

Register an app

Create an application in the developer portal. You'll receive a client_id and optionally a client_secret (for confidential clients).

Provide at least one redirect URI. Redirect URIs are validated exactly — no wildcards.

Authorization URL

Redirect the user to:

GET /api/id/authorize?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&
  response_type=code&
  scope=openid+profile+email&
  state=RANDOM_STATE&
  code_challenge=BASE64URL_SHA256&
  code_challenge_method=S256

For public clients (SPAs, mobile apps), PKCE is required. Generate a random code_verifier, compute code_challenge = BASE64URL(SHA256(code_verifier)), and send both.

Token exchange

POST /api/id/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&code_verifier=YOUR_VERIFIER

Response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "openid profile email"
}

UserInfo response

GET /api/id/userinfo
Authorization: Bearer ACCESS_TOKEN

Returns claims based on granted scopes:

{
  "sub": "ABCDEFGH",
  "ucid": "ABCD EFGH",
  "display_name": "J. Example",
  "legal_name": "John Example",
  "email": "[email protected]",
  "email_verified": true,
  "verification_tier": "ID2",
  "verification_level": 2,
  "age_over_18": true
}

Scopes reference

ScopeClaims returnedMin level
openidsub (UCID raw)ID1
uciducid (formatted XXXX XXXX)ID1
profiledisplay_name, legal_nameID1
emailemail, email_verifiedID1
verification_levelverification_tier (ID0–ID5), verification_level (0–5)ID1
verified_ageage_over_18 (boolean, not the DOB)ID2
birthdatedate_of_birth (full DOB)ID2
identity_statusidentity_statusID1
profile_photoprofile_photo object (ID3+)ID3

Protected profile photo

If the user has an approved photo and you have profile_photo scope, userinfo returns:

"profile_photo": {
  "available": true,
  "access": "protected_endpoint",
  "endpoint": "/api/id/media/profile-photo",
  "scope_required": "profile_photo"
}

Fetch the image with:

GET /api/id/media/profile-photo
Authorization: Bearer ACCESS_TOKEN

Returns raw JPEG bytes. Cache-Control: no-store is always set. Every access is audit-logged.

Verified age example

Request only verified_age for age-restricted services — you get a boolean, not the user's date of birth:

// Authorization URL scope
scope=openid+verified_age

// UserInfo response
{
  "sub": "ABCDEFGH",
  "age_over_18": true
}

The user's actual date of birth is never sent unless you explicitly request the birthdate scope — and the user consents to that specifically.

Security model

PKCE required for public clients. SPAs and mobile apps must use PKCE (S256). Confidential clients must use client_secret.

Short-lived codes. Authorization codes expire in 10 minutes and are single-use.

Passkey-only authentication. Users authenticate with device-bound passkeys. No passwords, no SMS codes, no TOTP. Phishing-resistant by design.

Session cookies. HttpOnly, Secure, SameSite=Strict. 7-day expiry.

Audit logging. Every token issuance, userinfo access, and photo access is audit-logged with timestamp, IP, and client_id.

Encrypted photos. Profile photos are stored encrypted at rest in R2 (AES-256-GCM). The key is a Cloudflare secret, never in the codebase.

JavaScript integration example

// 1. Generate PKCE challenge
async function generatePKCE() {
  const verifier = crypto.getRandomValues(new Uint8Array(32));
  const b64 = btoa(String.fromCharCode(...verifier))
    .replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
  const hash = new Uint8Array(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(b64))
  );
  const challenge = btoa(String.fromCharCode(...hash))
    .replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
  return { verifier: b64, challenge };
}

// 2. Start auth flow
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = new URL('https://id.pajic.it/api/id/authorize');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile verified_age');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();

// 3. Handle callback — exchange code for token
const code = new URLSearchParams(location.search).get('code');
const res = await fetch('https://id.pajic.it/api/id/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code', code,
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: sessionStorage.getItem('pkce_verifier')
  })
});
const { access_token } = await res.json();

// 4. Fetch user info
const user = await (await fetch('https://id.pajic.it/api/id/userinfo', {
  headers: { Authorization: `Bearer ${access_token}` }
})).json();
console.log(user.age_over_18, user.display_name);

OIDC discovery

The full discovery document is available at:

GET https://id.pajic.it/api/id/.well-known/openid-configuration