Signing people in: Pamodzi Pass and ZamPass
When your system needs to know who a person is, it relies on the national sign-in instead of keeping passwords. Today that is ZamPass. Pamodzi Pass, the national single sign-on being introduced, will replace it; it is under active development and ZamPass stays in use during the transition. In this lesson you learn the OpenID Connect flow both use, how to validate what you receive, and how to read assurance correctly.
The flow
Both providers use the OpenID Connect authorization code flow. Pamodzi Pass allows no other flow for people and requires PKCE (S256) from every client.
- Your server makes
state,nonceand a PKCEcode_verifier, and keeps them in the user's server-side session. - It redirects the browser to the provider's authorization endpoint, read from discovery
(
/.well-known/openid-configuration), withcode_challengeandcode_challenge_method=S256. - The person signs in at the provider and is sent back with
codeandstate. - Your server checks
state, then exchanges the code (with thecode_verifier) at the token endpoint. - Your server validates the ID token, fetches userinfo, and checks the userinfo
subequals the ID tokensub. - Your server starts its own session; the browser gets only an
HttpOnlycookie.
const pending = { state: rand(), nonce: rand(), verifier: rand() };
req.session.oidc = pending;
const url = new URL(meta.authorization_endpoint);
url.search = new URLSearchParams({
response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: 'openid profile',
state: pending.state, nonce: pending.nonce, code_challenge_method: 'S256',
code_challenge: crypto.createHash('sha256').update(pending.verifier).digest('base64url'),
}).toString();
res.redirect(url.toString());
Validating the ID token
Refuse the sign-in if any check fails:
| Check | Rule |
|---|---|
| Signature | A key from the provider's jwks_uri; an asymmetric algorithm only (ES256 for Pamodzi Pass); never none or HS* |
iss | Exactly the issuer from discovery |
aud | Contains your client id |
azp | Equal to your client id when present or when aud has several values |
exp, iat | Not expired, not in the future, recent |
nonce | The nonce you stored for this attempt |
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: ISSUER, audience: CLIENT_ID, algorithms: ['ES256'], clockTolerance: 60, maxTokenAge: '10m',
});
if ((([].concat(payload.aud)).length > 1 || payload.azp !== undefined) && payload.azp !== CLIENT_ID) throw new Error('azp');
if (payload.nonce !== pending.nonce) throw new Error('nonce');
TLS protects the connection; it does not catch a token minted for another client or a misconfigured issuer. That is why every check matters.
Registering with Pamodzi Pass
A system manager requests the system in Pamodzi Pass administration: its kind (web, spa, native or service), its identifier (for example example-partner), environment, https return addresses (no wildcards, no fragments; http://localhost only for development), after-sign-out and back-channel logout addresses, scopes, roles, and a DPA reference if it asks for national_id. An administrator approves it, never the person who requested it. The system manager then collects the client secret once.
Claims and assurance
| Scope | Claims |
|---|---|
openid | sub, ial |
profile | name, given_name, middle_name, family_name, birthdate, gender, locale, updated_at |
email / phone | email, email_verified / phone_number, phone_number_verified |
national_id | nrc, nrc_verified, nrc_verification, passport fields, nationality |
staff | staff, org, clearance, and roles in your system only |
Three signals tell you how much to trust a sign-in:
ial: 1 self-asserted, 2 national id verified remotely, 3 verified in person;acr:urn:pamodzi:aal1(password or one code) orurn:pamodzi:aal2(with a second factor);nrc_verified: always sent withnrc; an unverified NRC is never presented as verified.
Staff, people holding a role that requires MFA, and anyone signing in to a system registered with a minimum of aal2 must reach aal2. Staff use an authenticator app. Check acr yourself before you open anything sensitive.
ZamPass today
ZamPass is Ory Hydra with a customised Ory Kratos and a token hook that adds National Identity Register claims. Its vocabulary differs:
firstNameandlastNameinstead ofgiven_nameandfamily_name;verifiedandtrustLevel(verifiedmeanstrustLevel > 1) instead ofialandnrc_verified;nationalIdin thedocumentscope, released even whenverifiedis false;amrin Kratos method names and noacr;- one
subfor every system.
The platform maps this in one adapter: an NRC counts as verified only when verified === true; a verified NRC gives substantial assurance, an unverified one low; high needs a second factor on a verified identity; a staff account is never linked by NRC silently.
Logout
Use RP-initiated logout to the provider's end_session_endpoint (send id_token_hint in a POST form, never in a URL), and register a back-channel logout address. A logout token is validated like an ID token, with events containing the back-channel logout event, no nonce, a sid (or sub) naming the sessions to end, and a jti you refuse to accept twice.
What never to do
- Trust a self-declared or unverified NRC to link accounts or grant access.
- Put tokens, codes or NRCs in URLs.
- Store tokens in
localStorageorsessionStorage. - Use an ID token as an API bearer token.
- Log tokens, claims or NRCs.
Preparing for the move
Write your sign-in against standard OpenID Connect with a library, and keep the provider's claim names in one mapping. When Pamodzi Pass is switched on for your system, you add a mapping and change configuration. A planned "Continue with ZamPass" bridge in Pamodzi Pass (PP_ZAMPASS_* settings) will let people bring their verified ZamPass details across once; it is not built yet. If your system only calls the platform's APIs, nothing changes for you: partner tokens come from the platform, not from the identity provider.
Exercise
Start the platform's provider test doubles (npm run stack:providers in zcm-backend) and sign in to a development relying party as the citizen-verified and citizen-unverified personas of the mock ZamPass. Compare verified, trustLevel and nationalId in what you receive, and decide which persona may link to an existing record.
Check your understanding
- Which values does your server keep for one sign-in attempt, and where?
- Name five claims you must check in an ID token.
- What is the difference between
ialandacr? - Why is a ZamPass
nationalIdnot enough to link an account? - Where should a relying party keep the tokens it receives?
- What changes for a system that only uses the Partner API when the platform moves to Pamodzi Pass?
Sign in to record your progress.
Previous: Verifying issued documents Next: Payments: quotes, ZamPay and one finalisation path