A verified-human login button — like “Login with Google”, but the person is KYC-verified on their own phone. You never store passwords or handle Aadhaar.
Your users click a button on your site, approve on their phone with a fingerprint, and land back on your site — verified. You get their verified name; nothing else unless they consent. Not technical? Use the drop-in button below (2 lines), or send this page + your Client ID to whoever manages your website.
idp_ab12cd34…) — and the secret if it’s a server-side app.No backend needed. Register a “App with no server” at signup. Two lines add this button:
🔐 Login with idpass ← this is what your users see
<script src="https://idpass.in/sdk/idpass.js"
data-client-id="idp_YOUR_ID"
data-redirect-uri="https://your-site.com/callback"
data-button="#idpass-login"></script>
<div id="idpass-login"></div><script src="https://idpass.in/sdk/idpass.js"
data-client-id="idp_YOUR_ID"
data-redirect-uri="https://your-site.com/callback"></script>
<script>
const res = await idpass.handleRedirect(); // null if no login in progress
if (res) {
console.log("Signed in as", res.claims.name); // verified name
// res.claims = { sub, name, dob_verified, loa, ... }
// now create a session for res.claims.sub on your side
}
</script>The SDK handles the secure exchange, verifies the token, and only works from the web address you registered. That’s the whole integration.
Register a “Normal website” (you get a secret). Point any OpenID Connect library at our discovery URL — done.
Discovery URL: https://atithipass.com/portal/.well-known/openid-configuration
import { Issuer, generators } from "openid-client";
const iss = await Issuer.discover("https://idpass.in");
const client = new iss.Client({
client_id: "idp_YOUR_ID", client_secret: "YOUR_SECRET",
redirect_uris: ["https://your-site.com/callback"], response_types: ["code"],
});
// start login
const cv = generators.codeVerifier(); req.session.cv = cv;
res.redirect(client.authorizationUrl({
scope: "openid profile",
code_challenge: generators.codeChallenge(cv), code_challenge_method: "S256",
}));
// on /callback
const params = client.callbackParams(req);
const tokenSet = await client.callback("https://your-site.com/callback", params,
{ code_verifier: req.session.cv });
const user = tokenSet.claims(); // { sub, name, dob_verified, loa, ... }For developers/fintechs who prefer no shared secret. Register an “Extra-secure (key pair)” app and paste your public key. Make one in 30 seconds:
openssl genrsa -out my_private_key.pem 2048 openssl rsa -in my_private_key.pem -pubout -out my_public_key.pem cat my_public_key.pem # paste this output at signup
Keep my_private_key.pem secret on your
server. Your OIDC library signs a short “client assertion” with it — no secret ever leaves
your machine.
Wiring it by hand — PHP, Go, or any stack without an OIDC library? idpass is
plain OpenID Connect (Authorization Code + PKCE). Four HTTP steps, no magic. This is
the section to read if your callback gave you a code and state and
you’re not sure what to do next.
There is no decryption step and no decryption key —
if you went looking for one, that’s why you couldn’t find it. The id_token you
get back is a JWS (a JWT with three dot-separated parts). The user’s details are the
middle part, base64url-encoded. You verify its signature with our public
key from https://atithipass.com/portal/oidc/jwks.json, then read the JSON. That decoded JSON
is the user object.
Generate a random state and a PKCE code_verifier; the
code_challenge is its SHA-256. Keep the verifier in the user’s session —
you need it again in Step 3.
https://idpass.in/oidc/authorize ?client_id=idp_YOUR_ID &redirect_uri=https://your-site.com/callback # must match a registered URI exactly &response_type=code &scope=openid%20profile &state=RANDOM_STATE # you generate; compared in step 2 &nonce=RANDOM_NONCE # you generate; compared in step 4 &code_challenge=BASE64URL( SHA256(code_verifier) ) &code_challenge_method=S256 # PKCE is mandatory
function b64url($b){ return rtrim(strtr(base64_encode($b),'+/','-_'),'='); }
$_SESSION['state'] = b64url(random_bytes(16));
$_SESSION['nonce'] = b64url(random_bytes(16));
$_SESSION['verifier'] = b64url(random_bytes(32));
$challenge = b64url(hash('sha256', $_SESSION['verifier'], true)); // raw=true, then b64url
$url = "https://idpass.in/oidc/authorize?".http_build_query([
"client_id"=>"idp_YOUR_ID","redirect_uri"=>"https://your-site.com/callback",
"response_type"=>"code","scope"=>"openid profile",
"state"=>$_SESSION['state'],"nonce"=>$_SESSION['nonce'],
"code_challenge"=>$challenge,"code_challenge_method"=>"S256"]);
header("Location: $url");After they approve on their phone, idpass redirects to your
redirect_uri with two query params. First check state equals what
you stored (CSRF protection) — if not, stop.
code = code_ADBC53QWgSoqsJwzz2xQYno6Fox9AV9J # one-time; swap it in step 3 state = w2UJuW6_6faWwfNQDOTV6g # must equal $_SESSION['state']
POST to the token endpoint with the code and the same
code_verifier from Step 1. Send client_secret too if yours is a
server-side (“Normal website”) app.
curl -s https://idpass.in/oidc/token \ -d grant_type=authorization_code \ -d code=code_ADBC53QWgSoqsJwzz2xQYno6Fox9AV9J \ -d redirect_uri=https://your-site.com/callback \ -d client_id=idp_YOUR_ID \ -d client_secret=YOUR_SECRET \ # omit only for a public (no-server) app -d code_verifier=THE_SAME_VERIFIER_FROM_STEP_1
The response is JSON:
{ "access_token": "…", "id_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600 }.
Verify the id_token signature against our JWKS (algorithm
RS256), then its payload is your verified user. Also confirm nonce matches
what you sent, and aud equals your client_id.
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
$jwks = json_decode(file_get_contents("https://idpass.in/oidc/jwks.json"), true);
$claims = JWT::decode($tok['id_token'], JWK::parseKeySet($jwks)); // verifies RS256 signature
// $claims is your user:
// $claims->name, $claims->dob_verified, $claims->loa,
// $claims->sub (stable id, unique to YOUR app), $claims->unique_id
if ($claims->aud !== "idp_YOUR_ID") die("wrong audience");
if ($claims->nonce !== $_SESSION['nonce']) die("nonce mismatch");
// -> log the user in as $claims->subPrefer an API call over decoding? Send the
access_token (not the id_token) as a Bearer token to
https://atithipass.com/portal/oidc/userinfo and it returns
{ sub, name, dob_verified, loa }.
| You see | Cause & fix |
|---|---|
invalid_grant | The most common one. Either: (a) your
redirect_uri in Step 3 doesn’t byte-for-byte match Step 1 and your
registration; (b) the code_verifier doesn’t match the
code_challenge you sent (regenerated it, or lost the session); or (c)
the code was already used or expired — codes are single-use, exchange
immediately. |
invalid_client | Server-side app missing or wrong
client_secret. (A public / no-server app must not send one.) |
origin not allowed | A public app called the token endpoint from a web origin you didn’t register. Add the origin to your app. |
invalid_token at /userinfo | You sent the
id_token. /userinfo takes the access_token only. |
| Claim | Meaning |
|---|---|
sub | Stable user id — unique to your app (two portals can’t link the same person). |
name | Government-verified full name. |
dob_verified | true — date of birth is verified (the DOB itself stays private by default). |
loa | Level of assurance (2 = government-verified). |
device_bound | true — the login is tied to the user’s enrolled phone. |
device_attested | true only if the phone’s key passed hardware attestation (TEE/StrongBox). If you require a hardware-bound credential, gate on this. |
attest_level | The device key grade: strongbox / tee (hardware) · none (software key). |
amr | Authentication methods. Contains hwk when the key is hardware-attested, else swk (software key); plus mfa, user. |
unique_id | A stable per-app hash of the person — same human returning to your app always yields the same unique_id (use it to detect duplicate accounts), yet it can’t be linked across different apps. |
sub vs unique_id | Both are stable and app-scoped. sub is the account/session subject; unique_id is the anti-duplicate signal for the underlying person. |
Beyond the base identity, you can request additional verified fields by adding scopes. At sign-in the user sees a per-field toggle and chooses what to share — so a field arrives only if the user consents. If they decline, the claim is simply absent (design your flow to handle that).
| Scope to request | Claim you receive | Meaning |
|---|---|---|
idpass:address | address | Government-verified address (single string). |
idpass:aadhaar_last4 | aadhaar_last4 | The masked Aadhaar (XXXXXXXX1234) — last 4 digits only. |
scope=openid%20profile%20idpass:address%20idpass:aadhaar_last4
The full Aadhaar number is never shared — only the
masked last-4. The scope in the token response tells you which optional fields the
user actually granted. Users can also present single facts (e.g. “over 18”) without revealing
anything else.
Need higher assurance? idpass supports bank-grade options (sender-constrained tokens, pushed requests, a regulated profile) and agent-assisted / call-centre login where the request is pushed to the user’s phone. Turn these on per app, or ask us — the discovery document lists everything a library needs automatically.
The error idpass returns tells you precisely what to fix. The two you’re most likely to hit when wiring it by hand:
invalid_grant at the token step? 9 times out of 10 it’s PKCE.
You must send the same code_verifier
at /oidc/token that you used to build the code_challenge for
/oidc/authorize. Store it in the user’s session between the two steps and don’t
regenerate it. If the verifier is fresh (or empty), the challenge can’t match and the
exchange is rejected.
| Error | What it means & how to fix it |
|---|---|
invalid_clientHTTP 401 |
Your app failed to authenticate at the token step. Server-side app: send the
correct client_secret (as a form field, or HTTP Basic
client_id:client_secret). No-server / SPA app: do not send a
secret at all — identity is proven by PKCE. Lost your secret? Register a fresh app or ask
us to reissue it. |
invalid_grantHTTP 400 |
The authorization code or PKCE didn’t validate. Check, in order:
(1) the code_verifier matches the code_challenge you sent
(see the box above — the usual cause); (2) you’re exchanging the code once and
promptly — codes are single-use and expire quickly; (3) the
redirect_uri at the token step is byte-for-byte identical to the one in
the authorize request and your registration. |
invalid_request · code_challenge (PKCE) required |
You didn’t send PKCE. Always include code_challenge +
code_challenge_method=S256 on the authorize request — it’s mandatory. Also make
sure scope is openid profile, not empty or undefined. |
invalid_token at /userinfo |
You sent the id_token. The userinfo endpoint takes the
access_token from the same token response — pass that as
Authorization: Bearer instead. |
| Scanning the QR does nothing | The person needs the idpass app installed and enrolled first (one-time DigiLocker verification). An un-enrolled phone can’t approve a login. |
Still stuck after checking the above? Send us your
client_id and the time of a failed attempt — we can see the exact reason
server-side and tell you which of these it is.
Prefer Postman? Import the
collection above, set your client_id/secret, and run the requests.