DigiLocker eKYC
DigiLocker is operated by NeGD (National e-Governance Division). Linking an employee's DigiLocker account to Sanchalan gives us a verified eKYC record (name, DOB, eKYC photo) without needing an AUA license. We use it as the legal identity proof for first-time face-template enrollment from the mobile app.
What's shipped
attendance_digilocker_linkstable — Aadhaar-correlation hash, name+DOB match flags, encrypted access token, sanitised claims.DigiLockerClientservice: PKCE OAuth2 flow, eKYC XML parser (XXE-defused).DigiLockerController: status / start / callback (web) + mobile-start / mobile-exchange (token).POST /api/v1/enrollment/face-template/digilocker: self-enrollment gated by a fresh (≤15 min) link with name + DOB matching the employee record.- Mobile screen
DigiLockerEnrollScreen— full PKCE flow viaflutter_web_auth_2, embeds the DigiLocker photo + a live face frame, averages, submits.
End-to-end flow
sequenceDiagram
participant U as Employee
participant App as Sanchalan app
participant Be as backend (Sanchalan)
participant DL as DigiLocker (NeGD)
U->>App: tap "Enroll with DigiLocker"
App->>App: generate PKCE (verifier, challenge, state)
App->>Be: GET /api/digilocker/mobile/start?code_challenge=…&state=…
Be-->>App: {authorization_url}
App->>DL: open authorization_url (in-app browser)
DL->>U: login + consent (DigiLocker UI)
DL-->>App: redirect sanchalan-attendance://dl/cb?code=…&state=…
App->>Be: POST /api/v1/digilocker/mobile/exchange
{code, code_verifier} Be->>DL: POST /oauth2/1/token (server-side; client_secret here) DL-->>Be: {access_token} Be->>DL: GET /oauth2/3/user Be->>DL: GET /oauth2/2/xml/eaadhaar DL-->>Be: eKYC XML (name, DOB, gender, last-4 Aadhaar, photo) Be->>Be: parse XML, compare name + DOB with employee_register Be->>Be: persist link (encrypted token + sanitised claims; no raw Aadhaar) Be-->>App: {link_id, name_match, dob_match, photo_b64} App->>App: embed DigiLocker photo with MobileFaceNet App->>App: live capture (1 frame) + on-device liveness App->>App: average DL embedding + live embedding → unit vector App->>Be: POST /api/v1/enrollment/face-template/digilocker
{link_id, embedding, model, frames_used, consent} Be->>Be: re-validate link (≤15 min, name_match, dob_match) Be->>Be: encrypt + store template (AES-256-GCM) Be-->>App: 201 {template_id, version, identity_proof: 'digilocker_eaadhaar'}
{code, code_verifier} Be->>DL: POST /oauth2/1/token (server-side; client_secret here) DL-->>Be: {access_token} Be->>DL: GET /oauth2/3/user Be->>DL: GET /oauth2/2/xml/eaadhaar DL-->>Be: eKYC XML (name, DOB, gender, last-4 Aadhaar, photo) Be->>Be: parse XML, compare name + DOB with employee_register Be->>Be: persist link (encrypted token + sanitised claims; no raw Aadhaar) Be-->>App: {link_id, name_match, dob_match, photo_b64} App->>App: embed DigiLocker photo with MobileFaceNet App->>App: live capture (1 frame) + on-device liveness App->>App: average DL embedding + live embedding → unit vector App->>Be: POST /api/v1/enrollment/face-template/digilocker
{link_id, embedding, model, frames_used, consent} Be->>Be: re-validate link (≤15 min, name_match, dob_match) Be->>Be: encrypt + store template (AES-256-GCM) Be-->>App: 201 {template_id, version, identity_proof: 'digilocker_eaadhaar'}
What we store / what we don't
| Data | Where | Encryption |
|---|---|---|
| Raw Aadhaar number | Never persisted. | — |
| Aadhaar-correlation hash | attendance_digilocker_links.aadhaar_hash — one row per (Aadhaar, employee). Used to flag the same Aadhaar trying to link to multiple employees. | Vault-salted SHA-256. |
| Raw DOB | Never persisted. Only dob_year + match boolean. | — |
| eKYC photo bytes | Never persisted. Returned once on the exchange response so the mobile can compute an embedding. Hash stored for tamper-detect. | — |
| DigiLocker access token | attendance_digilocker_links.access_token_enc (Laravel Crypt-wrapped). Used only to refresh the link if needed. | AES-256-CBC + HMAC. |
| Refresh token | Never persisted. If we need refresh, we re-prompt the user. | — |
| Sanitised claims | attendance_digilocker_links.claims JSONB: {name, dob_year, gender, last_4} | — |
Why DigiLocker for parliament use cases
- No AUA license required. DigiLocker uses the user's own Aadhaar consent — Sanchalan is just a relying party, not authenticating against UIDAI ourselves.
- Voluntary by design. The user signs in with their DigiLocker credentials; non-coercive.
- Stronger than HR-mediated alone. The match between the DigiLocker photo and the live capture is cryptographically tied to the user's verified eKYC record.
- Suits MPs. No AUA / §7 awkwardness. DigiLocker is widely accepted for member-of-public use cases.
- Operational simplicity. Just a client_id + client_secret pair, no ASA gateway, no annual UIDAI audit.
Operator setup
- Apply for an "agency" registration on DigiLocker (Partner Onboarding portal). Receive
client_id+client_secret. - Register two redirect URIs:
- Web:
https://attendance.rajyasabha.digital/api/digilocker/callback - Mobile:
sanchalan-attendance://dl/cb
- Web:
- Populate
.env:DIGILOCKER_ENABLED=true DIGILOCKER_BASE_URL=https://digilocker.meripehchaan.gov.in DIGILOCKER_CLIENT_ID=… DIGILOCKER_CLIENT_SECRET=… DIGILOCKER_REDIRECT_URI=https://attendance.rajyasabha.digital/api/digilocker/callback DIGILOCKER_AADHAAR_SALT=$(openssl rand -hex 32) - Recreate the app container so config is reloaded.
- Verify
GET /api/digilocker/status→{"enabled":true,"has_client_id":true}. - Pilot with a small group (10–20 employees) before announcing wider availability.
Wire format reference
POST /api/v1/digilocker/mobile/exchange
// request
{
"code": "...", // from the redirect's ?code=...
"code_verifier": "..." // PKCE — 43-128 url-safe chars
}
// response (link persisted)
{
"link_id": 42,
"employee_id": 21,
"name_match": true,
"dob_match": true,
"has_photo": true,
"photo_b64": "..." // ONCE — never persisted server-side
}
POST /api/v1/enrollment/face-template/digilocker
// request
{
"link_id": 42, // must be ≤15 minutes old, name+dob match
"embedding": "...", // base64 of 512-d float32 (LE)
"model": "mobilefacenet",
"frames_used": 2,
"consent": {
"version": "1",
"text_url": "/static/face-consent-v1.html",
"signature_b64": null
}
}
// response (201)
{
"template_id": 1234,
"version": 1,
"enrolled_at": "2026-04-27T19:30:00.000000Z",
"identity_proof": "digilocker_eaadhaar"
}
What happens on a name/DOB mismatch
The link is still persisted (with name_match=false / dob_match=false) for forensic record, but POST /enrollment/face-template/digilocker returns 403. The user must visit HR to enroll in person, or fix the discrepancy in their DigiLocker / employee_register record.
Threat model
| Threat | Mitigation |
|---|---|
| Stolen mobile + open DigiLocker session | Punch still requires the device's BiometricPrompt + the live face match. DigiLocker alone doesn't grant attendance. |
| Fraudulent DigiLocker photo (deepfake) | Live capture during enrollment is averaged with the eKYC photo. A deepfake at enrollment would need to also match the user's actual face at every subsequent punch (which goes through the same liveness gate). |
| Same Aadhaar linking to two employees | aadhaar_hash uniqueness check at link time — second attempt rejected with audit alert. |
| Stale link reused after employee separation | Hard 15-minute window on the link. After that, employee must re-link. |
| DigiLocker access token leak | Encrypted with Laravel Crypt + APP_KEY. Per-token; revoking APP_KEY invalidates all stored tokens. Future: Vault transit. |