On this page
Sign in with Pamodzi Pass
Pamodzi Pass is the national identity provider being introduced for Zambia's government systems. Citizens, residents and government staff sign in once and use that sign-in across the national portal, the ministry back offices, POS terminals and any other registered system. It replaces ZamPass.
Status. Pamodzi Pass is under active development. Its protocol core, accounts, sign-in with a second factor, registration, recovery, account pages and administration logic are written; the administration pages, the ZamPass bridge and the switch-over of the e-Services platform are in progress or next. ZamPass remains the sign-in in use during the transition. Build against the standards described here and test against a development instance; read ZamPass and the move to Pamodzi Pass for what to do today.
Pamodzi Pass speaks standard OpenID Connect and OAuth 2.0. It is built on oidc-provider, an OpenID Certified implementation, so any mature OpenID Connect library in any language works with it. This guide explains the choices Pamodzi Pass makes and what your system must check.
The choices, in one table
| Topic | Pamodzi Pass |
|---|---|
| Flows | Authorization code flow only for people. No implicit or hybrid flow. Client credentials only for system-to-system calls. |
| PKCE | Required for every client, method S256 |
| ID token signature | ES256 |
| Lifetimes (defaults) | Authorization code 60 seconds; access token and ID token 10 minutes; session 8 hours (staff 10 hours) |
| Refresh tokens | Only with the offline_access scope; rotated on every use |
| Assurance | acr = urn:pamodzi:aal1 or urn:pamodzi:aal2; amr in RFC 8176 values; ial 1, 2 or 3 |
| Subjects | Public subjects for government systems; pairwise subjects for systems registered so (third parties) |
| Logout | RP-initiated logout with a confirmation page; back-channel logout to every system that registered a URI |
| Other | Discovery, userinfo, token introspection (your own tokens only), revocation, pushed authorization requests (optional) |
Discovery
Everything your library needs is in the discovery document:
GET https://<pamodzi-pass-issuer>/.well-known/openid-configuration
Read the endpoints from it; do not hard-code paths. Check that its issuer is exactly the issuer you configured, and cache it (an hour is reasonable). Keys come from jwks_uri; when a token names a kid you do not have, fetch the key set again once. Pamodzi Pass publishes the next signing key before it starts signing with it, so a rotation never breaks a client that follows this rule.
Registering your system
A system becomes a client only through the systems registry, with maker-checker:
- A system manager requests it in Pamodzi Pass administration. The request names:
- the kind:
web(server-side, confidential),spaornative(public clients, no secret), orservice(client credentials only, never a person);
- the system identifier (
client_id): 3 to 64 lower-case letters, digits, dots, dashes or underscores, for exampleexample-partner; - the environment:
production,staging,testordevelopment; - the return (redirect) addresses:
httpsonly, with no fragment and no wildcard.http://localhostis acceptedonly for a
developmentsystem; native apps may use a reverse-domain scheme; - the after-sign-out addresses and, optionally, a back-channel logout address;
- the scopes it may ask for. A system that receives national id numbers (
national_id) must give its dataprotection (DPA) reference;
- the audience (
public,stafforboth) and the system's roles, each with an optional minimum securityclearance and whether it requires MFA (the default is yes).
- the kind:
- An administrator approves it. The person who requested a system can never approve it. Only an administrator can
make a system "first-party" (implicit consent); everyone else's system shows the person a consent page.
- The system manager collects the client secret once (confidential clients). Rotating it later is the same action.
Only an active system is a client. Suspending or retiring a system makes the provider answer invalid_client at once; there is no grace period.
The sign-in, step by step
- Your server creates three random values for this attempt and keeps them server-side in the user's session:
state,nonceand a PKCEcode_verifier. - It redirects the browser to the
authorization_endpointwithresponse_type=code,client_id,redirect_uri,scope,state,nonce,code_challenge(base64url SHA-256 of the verifier) andcode_challenge_method=S256. Addacr_values=urn:pamodzi:aal2when you need a second factor. - The person signs in at Pamodzi Pass (password, then a second factor when needed, then consent if your system
requires it).
- Pamodzi Pass redirects back with
codeandstate. Checkstateagainst the session, then exchange the code atthe
token_endpointwith thecode_verifier, authenticating as your client. The code lives 60 seconds and works once. - Validate the ID token (checklist below). Then call the
userinfo_endpointwith the access token for the person'sclaims, and check that the userinfo
subequals the ID token'ssub. - Start your own session. Keep any Pamodzi Pass tokens on the server.
ID token validation checklist
Refuse the sign-in if any of these fails:
| Check | Rule |
|---|---|
| Signature | Verify with a key from jwks_uri. Accept ES256 only; never none, never an HS* algorithm. |
iss | Exactly equal to the issuer from discovery |
aud | Contains your client_id |
azp | If present, or if aud has more than one value, equal to your client_id |
exp | In the future (allow at most a minute of clock skew) |
iat | Present, not in the future, and recent (the token lives 10 minutes) |
nonce | Equal to the nonce you stored for this attempt; then delete the stored nonce |
acr | At least what this action needs (urn:pamodzi:aal2 for staff and sensitive actions) |
auth_time | If you sent max_age, not older than that |
userinfo sub | Equal to the ID token sub |
Scopes and claims
| Scope | Claims | Notes |
|---|---|---|
openid | sub, ial | Always required for a person's sign-in |
profile | name, given_name, middle_name, family_name, birthdate, gender, locale, updated_at | |
email | email, email_verified | |
phone | phone_number, phone_number_verified | E.164 format |
national_id | nrc, nrc_verified, nrc_verification { method, source, time }, passport_number, passport_country, nationality | Only for systems allowed the scope, with a DPA reference, and with the person's consent where required |
staff | staff, org { code, name }, clearance { level, valid_until }, roles [{ role, org }] | roles are the person's roles in your system only, never another system's grants. A role whose minimum clearance the person does not meet is withheld. |
offline_access | (refresh token) | Only when your system genuinely needs to act while the person is away |
Ask only for what you need: a reviewer will ask why for each scope, and the person sees the list on the consent page.
Assurance levels
ial(identity assurance):1self-asserted,2national id verified remotely,3verified in person.acr(how strongly they signed in this time):urn:pamodzi:aal1is a password or one code;urn:pamodzi:aal2is a password and a second factor (authenticator app, recovery code, or an SMS or e-mail code).amrlists the methods in RFC 8176 values, for example["pwd"]or["pwd", "otp", "mfa"].nrc_verifiedis always sent withnrc. An unverified NRC is released withnrc_verified: false, never as ifit were verified.
Decide per action what you need, for example: reading public information needs no sign-in; applying for a service may need ial 2 or more; approving a payment needs aal2.
Staff and MFA
A staff account, anyone holding a role in your system that requires MFA, and any sign-in to a system registered with a minimum of aal2 must reach urn:pamodzi:aal2, or the sign-in does not complete. Staff must use an authenticator app. A session signed in with one factor does not satisfy a system that needs two: Pamodzi Pass asks for the second factor again. Still check acr yourself: it is your system that must refuse a staff session without aal2.
A minimal Node.js relying party
This example uses Express, express-session (server-side sessions) and jose (JWT validation). Pin the versions you use. A certified OpenID Connect client library does the same work and is a good choice too.
// Node 20+, ES modules. Secrets come from the environment, which your secret store fills.
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = process.env.PP_ISSUER; // the Pamodzi Pass issuer, exactly
const CLIENT_ID = process.env.PP_CLIENT_ID; // e.g. example-partner
const CLIENT_SECRET = process.env.PP_CLIENT_SECRET;
const REDIRECT_URI = process.env.PP_REDIRECT_URI; // https://app.example/auth/callback, as registered
const meta = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
if (meta.issuer !== ISSUER) throw new Error('Discovery answered for another issuer');
const JWKS = createRemoteJWKSet(new URL(meta.jwks_uri));
const rand = () => crypto.randomBytes(32).toString('base64url');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));
app.get('/auth/login', (req, res) => {
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: crypto.createHash('sha256').update(pending.verifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString();
res.redirect(url.toString());
});
app.get('/auth/callback', async (req, res) => {
const pending = req.session.oidc;
delete req.session.oidc; // one attempt, one use
if (!pending || typeof req.query.state !== 'string' || req.query.state !== pending.state) return res.status(400).send('Sign-in expired. Try again.');
if (req.query.error) return res.status(401).send('Sign-in was not completed.');
const basic = Buffer.from(`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`).toString('base64');
const tokenRes = await fetch(meta.token_endpoint, {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code: String(req.query.code), redirect_uri: REDIRECT_URI, code_verifier: pending.verifier }),
});
if (!tokenRes.ok) return res.status(401).send('Sign-in failed.');
const tokens = await tokenRes.json();
try {
// signature, alg, iss, aud, exp, iat (and its age) in one call
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: ISSUER, audience: CLIENT_ID, algorithms: ['ES256'], clockTolerance: 60, maxTokenAge: '10m',
});
const auds = [].concat(payload.aud);
if ((auds.length > 1 || payload.azp !== undefined) && payload.azp !== CLIENT_ID) throw new Error('azp');
if (payload.nonce !== pending.nonce) throw new Error('nonce');
const info = await (await fetch(meta.userinfo_endpoint, { headers: { Authorization: `Bearer ${tokens.access_token}` } })).json();
if (info.sub !== payload.sub) throw new Error('userinfo sub');
req.session.regenerate((err) => {
if (err) return res.status(500).end();
// keep only what you need, on the server; never hand tokens to the browser
req.session.user = { sub: payload.sub, sid: payload.sid, acr: payload.acr, ial: info.ial, name: info.name };
req.session.idTokenHint = tokens.id_token; // for logout only
res.redirect('/');
});
} catch (e) {
return res.status(401).send('Sign-in could not be verified.');
}
});
app.listen(3000);
Logout
RP-initiated logout. To sign the person out of Pamodzi Pass as well as your system, end your own session and send the browser to the end_session_endpoint with your client_id, a registered post_logout_redirect_uri and a random state. If you send id_token_hint, send it in a POST form rather than in the query string, so the token does not end up in logs. Pamodzi Pass shows the person a confirmation page, then returns them to your address.
Back-channel logout. Register a back-channel logout address and Pamodzi Pass will POST a signed logout_token (form field) to it whenever the person signs out anywhere, so your sessions end too. Validate the logout token like an ID token, with these differences:
audis yourclient_id;iss, signature andiatas before;eventscontainshttp://schemas.openid.net/event/backchannel-logout;- there is no
nonce; sidis present (Pamodzi Pass registers clients with sessions required) and names the session to end;submay bepresent too;
- remember each
jtiuntil the token's expiry and refuse a repeat.
Then end every local session with that sid (or, with only sub, every session of that person) and answer 200. Answer 400 to a token that fails any check. Keep the endpoint rate-limited.
System-to-system calls (client credentials)
A system registered as kind service gets tokens with the client credentials grant, for one target system at a time: send resource=urn:pamodzi:system:<target client_id>. The access token is a JWT (ES256) whose audience is that target, carrying only the scopes your system was allowed for it (named <target>:<scope>). The target validates the token's signature, issuer, audience and expiry. This is separate from the platform's Partner API, which issues its own client-credentials tokens (see Partner API).
What never to do
- Never trust a self-declared NRC. Use an NRC to link records or grant access only when
nrc_verifiedistrue,and check
ialfor the level you need. A typed-in NRC proves nothing about who is signing in. - Never put tokens, codes or NRCs in URLs you build (query strings end up in logs and browser history). Use the
POSTform forid_token_hint. - Never store tokens in
localStorageorsessionStorage. Keep them on your server; give the browser only anHttpOnly,Securesession cookie. - Never use the ID token as an API bearer token. It proves a sign-in to your system; it is not an access token.
- Never skip a check in the validation list because "TLS already protects it": TLS does not catch a token minted
for another client or a misconfigured issuer.
- Never log tokens, claims or NRCs. Log the
suband an outcome. - Never silently link an existing staff account to an identity by matching an NRC or e-mail. Make the officer
prove control of both, or have an administrator approve the link.
Related: ZamPass and the move to Pamodzi Pass. The course Integration developer covers sign-in in its fourth lesson.