An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework
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

TopicPamodzi Pass
FlowsAuthorization code flow only for people. No implicit or hybrid flow. Client credentials only for system-to-system calls.
PKCERequired for every client, method S256
ID token signatureES256
Lifetimes (defaults)Authorization code 60 seconds; access token and ID token 10 minutes; session 8 hours (staff 10 hours)
Refresh tokensOnly with the offline_access scope; rotated on every use
Assuranceacr = urn:pamodzi:aal1 or urn:pamodzi:aal2; amr in RFC 8176 values; ial 1, 2 or 3
SubjectsPublic subjects for government systems; pairwise subjects for systems registered so (third parties)
LogoutRP-initiated logout with a confirmation page; back-channel logout to every system that registered a URI
OtherDiscovery, 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:

  1. A system manager requests it in Pamodzi Pass administration. The request names:
    • the kind: web (server-side, confidential), spa or native (public clients, no secret), or service

      (client credentials only, never a person);

    • the system identifier (client_id): 3 to 64 lower-case letters, digits, dots, dashes or underscores, for example

      example-partner;

    • the environment: production, staging, test or development;
    • the return (redirect) addresses: https only, with no fragment and no wildcard. http://localhost is accepted

      only for a development system; 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 data

      protection (DPA) reference;

    • the audience (public, staff or both) and the system's roles, each with an optional minimum security

      clearance and whether it requires MFA (the default is yes).

  2. 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.

  3. 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

  1. Your server creates three random values for this attempt and keeps them server-side in the user's session:

    state, nonce and a PKCE code_verifier.

  2. It redirects the browser to the authorization_endpoint with response_type=code, client_id, redirect_uri,

    scope, state, nonce, code_challenge (base64url SHA-256 of the verifier) and code_challenge_method=S256. Add acr_values=urn:pamodzi:aal2 when you need a second factor.

  3. The person signs in at Pamodzi Pass (password, then a second factor when needed, then consent if your system

    requires it).

  4. Pamodzi Pass redirects back with code and state. Check state against the session, then exchange the code at

    the token_endpoint with the code_verifier, authenticating as your client. The code lives 60 seconds and works once.

  5. Validate the ID token (checklist below). Then call the userinfo_endpoint with the access token for the person's

    claims, and check that the userinfo sub equals the ID token's sub.

  6. 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:

CheckRule
SignatureVerify with a key from jwks_uri. Accept ES256 only; never none, never an HS* algorithm.
issExactly equal to the issuer from discovery
audContains your client_id
azpIf present, or if aud has more than one value, equal to your client_id
expIn the future (allow at most a minute of clock skew)
iatPresent, not in the future, and recent (the token lives 10 minutes)
nonceEqual to the nonce you stored for this attempt; then delete the stored nonce
acrAt least what this action needs (urn:pamodzi:aal2 for staff and sensitive actions)
auth_timeIf you sent max_age, not older than that
userinfo subEqual to the ID token sub

Scopes and claims

ScopeClaimsNotes
openidsub, ialAlways required for a person's sign-in
profilename, given_name, middle_name, family_name, birthdate, gender, locale, updated_at
emailemail, email_verified
phonephone_number, phone_number_verifiedE.164 format
national_idnrc, nrc_verified, nrc_verification { method, source, time }, passport_number, passport_country, nationalityOnly for systems allowed the scope, with a DPA reference, and with the person's consent where required
staffstaff, 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): 1 self-asserted, 2 national id verified remotely, 3 verified in person.
  • acr (how strongly they signed in this time): urn:pamodzi:aal1 is a password or one code;

    urn:pamodzi:aal2 is a password and a second factor (authenticator app, recovery code, or an SMS or e-mail code).

  • amr lists the methods in RFC 8176 values, for example ["pwd"] or ["pwd", "otp", "mfa"].
  • nrc_verified is always sent with nrc. An unverified NRC is released with nrc_verified: false, never as if

    it 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:

  • aud is your client_id; iss, signature and iat as before;
  • events contains http://schemas.openid.net/event/backchannel-logout;
  • there is no nonce;
  • sid is present (Pamodzi Pass registers clients with sessions required) and names the session to end; sub may be

    present too;

  • remember each jti until 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_verified is true,

    and check ial for 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

    POST form for id_token_hint.

  • Never store tokens in localStorage or sessionStorage. Keep them on your server; give the browser only an

    HttpOnly, Secure session 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 sub and 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.