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
| Scope | Claims returned | Min level |
| openid | sub (UCID raw) | ID1 |
| ucid | ucid (formatted XXXX XXXX) | ID1 |
| profile | display_name, legal_name | ID1 |
| email | email, email_verified | ID1 |
| verification_level | verification_tier (ID0–ID5), verification_level (0–5) | ID1 |
| verified_age | age_over_18 (boolean, not the DOB) | ID2 |
| birthdate | date_of_birth (full DOB) | ID2 |
| identity_status | identity_status | ID1 |
| profile_photo | profile_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.
Consent behavior
When a user authorizes your app, their consent is stored. On subsequent authorization requests with the same scopes, consent is automatically granted and the user is not interrupted.
Users can revoke consent at any time from their dashboard. Revocation immediately invalidates all active tokens for that app+user pair.
If you request additional scopes on a later authorization, the consent screen is shown again for the new scopes.
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