Login with RDCPASS

OpenID Connect — let a citizen sign in to your application with their existing RDCPASS identity.

Endpoints

GET/v1/oidc/authorizeStep 1 — redirect the citizen here to authenticate and consent.
POST/v1/oidc/tokenStep 3 — exchange an authorization code for tokens.
GET/v1/oidc/userinfoRead the signed-in citizen’s profile with an access token.
GET/v1/oidc/jwks.jsonPublic keys for verifying an id_token’s signature. No authentication required.

Why this looks different from every other endpoint

These four endpoints are NOT HMAC-signed or AES-256-GCM-encrypted like the rest of the API — that's deliberate, not an oversight. OpenID Connect is a standard, and the entire point of offering it is that off-the-shelf OIDC libraries (Passport.js, Authlib, oidc-client, …) work against it unmodified. Layering RDCPASS's own signing scheme on top would break that compatibility for no security benefit PKCE and mTLS don't already provide. /v1/oidc/authorize is a browser redirect and carries no application secret at all — it's protected by the redirect_uri allowlist, state, and mandatory PKCE. /v1/oidc/token and /v1/oidc/userinfo still require your application's mTLS client certificate, the same one used everywhere else — just not HMAC signing or payload encryption.

Instead of building your own login form and calling KYC Validation yourself, redirect the citizen to RDCPASS. They approve your request on their own phone — a push notification, a review of exactly the KYC scopes you're asking for, and a face-recognition check, never a password — and RDCPASS redirects them back with proof of who they are: an id_token, signed and ready to verify, carrying the basic KYC claims and every additional KYC scope the citizen approved.

Register your redirect URIs first

Before any of this works, an Owner or Administrator of your organization must register the exact redirect_uri value(s) your application will use, from the console. /v1/oidc/authorize rejects any request whose redirect_uri isn't an exact, byte-for-byte match against one already on file — no wildcards, no partial matches. This is what stops a stolen client_id from being used to redirect a citizen's login to an attacker-controlled URL.

How it works

  1. 1

    Redirect to the authorization endpoint — Send the citizen’s browser to /v1/oidc/authorize with a fresh PKCE challenge and state value.

  2. 2

    Citizen approves on their phone — RDCPASS pushes a notification to their enrolled device. They review exactly the scopes you’re requesting and confirm with a face-recognition check — nothing is shared silently, and no password is ever involved.

  3. 3

    RDCPASS redirects back to you — Your redirect_uri receives an authorization code (or an error) as a query parameter, plus your original state value unchanged.

  4. 4

    Exchange the code for tokens — Your server calls /v1/oidc/token directly (never the browser) to trade the one-time code for an access token and id_token.

Step 1 — authorization request

Redirect the citizen’s browser to a URL shaped like this:

https://api.rdcpass.cd/v1/oidc/authorize
  ?response_type=code
  &client_id=app_8f2a1c0e9b
  &redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fcallback
  &scope=openid%20profile%20rdcpass%3Akyc.addresses%20rdcpass%3Akyc.phone_numbers%20rdcpass%3Akyc.emails%20rdcpass%3Akyc.documents.primary
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

Query parameters:

ParameterDescription
response_typeMust be exactly "code" — RDCPASS supports the authorization code flow only, not implicit or hybrid.
client_idYour application’s API Key.
redirect_uriWhere to send the citizen back. Must exactly match a URI already registered for this application.
scopeSpace-separated scopes: "openid" (required), "profile" for the basic KYC claims, and "rdcpass:<scope>" for each additional KYC scope granted to your application — e.g. "openid profile rdcpass:kyc.addresses rdcpass:kyc.phone_numbers". See Scopes and claims below.
stateAn opaque value your application generates. RDCPASS returns it unchanged on the callback — verify it matches what you sent before trusting the response, as CSRF protection.
code_challengebase64url(SHA-256(code_verifier)) — see the PKCE callout below.
code_challenge_methodMust be exactly "S256". The plaintext "plain" method is not supported.
nonceOptional. Echoed back inside the id_token unchanged — binds the token to this specific authorization request.

PKCE is mandatory, not optional

Every authorization request must include a code_challenge, and every token exchange must include the matching code_verifier — there's no confidential-client exception. Most integrations (mobile apps, single-page apps) can't hold a client secret safely, and RDCPASS doesn't issue one for this flow at all. PKCE proves the caller redeeming the code is the same one that started the request, which is what stops an intercepted authorization code from being usable by anyone else.

How citizens approve: push notification and face recognition

Everything below happens on RDCPASS’s own hosted page, reached by the Step 1 redirect — none of it is an API your application calls. It’s documented here so you can set the right expectations for your own users and support team.

  1. 1

    The citizen identifies themselves on RDCPASS’s page — their registered phone number or national identity document number. This never passes through your application; RDCPASS’s hosted page is the only place it’s entered.

  2. 2

    RDCPASS immediately pushes a notification to every device where the citizen has the RDCPASS app installed and enrolled. It names your application by its verified display name — resolved from your client_id, never something your request can set — and lists exactly the scopes you’re requesting, in plain language — no more, no less.

  3. 3

    Opening the notification shows the same consent screen in full inside the app — every requested scope, with sensitive and biometric scopes flagged — and Continue and Deny. The citizen approves or denies the request as a whole.

  4. 4

    Continue starts a face-recognition check, matched server-side against the citizen’s enrolled RDCPASS biometric record — the same enrollment used elsewhere on the platform, not just whatever unlocks their phone.

  5. 5

    The citizen has 60 seconds total, starting from the Step 1 redirect, to complete all of the above. Declining, failing the face match, or simply not responding in time all end the same way — see access_denied below.

A citizen with no RDCPASS app installed, or no enrolled biometric, is guided to set both up on RDCPASS’s own page before they can continue — there is no password fallback.

Why RDCPASS doesn’t say which one happened

Declined, failed face match, and timed out all redirect back as the identical access_denied — deliberately. Telling your application which one occurred would leak information about the citizen’s behavior they never agreed to share with you. Treat every access_denied the same way: offer to start again.

Step 2 — the callback

On success, RDCPASS redirects to your redirect_uri with a code:

https://yourapp.example.com/callback
  ?code=auth_9f2ac3d0e1b74a2f9c7d5b3a1e6f8c0d
  &state=af0ifjsldkj

If the citizen declines, or something goes wrong before a code can be issued, you get an error on the same redirect_uri instead — never a code and an error together:

https://yourapp.example.com/callback
  ?error=access_denied
  &error_description=The citizen declined to authorize your application
  &state=af0ifjsldkj

See OAuth error codes below for every value error can take.

Step 3 — exchange the code for tokens

From your server (never from the browser — this call requires your application’s mTLS client certificate), exchange the code:

ParameterDescription
grant_typeMust be exactly "authorization_code".
codeThe code from the callback. Single-use — a second exchange attempt with the same code fails with invalid_grant, and RDCPASS treats reuse as a signal the code may have been intercepted.
redirect_uriMust exactly match the redirect_uri used in the authorization request.
client_idYour application’s API Key — same value as the authorize request.
code_verifierThe original random string your code_challenge was derived from. RDCPASS re-derives the challenge and compares.

Example token exchange

exchange-code.sh
curl https://api.rdcpass.cd/v1/oidc/token \
  -X POST \
  --cert your-app-client-cert.pem \
  --key your-app-client-key.pem \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=auth_9f2ac3d0e1b74a2f9c7d5b3a1e6f8c0d" \
  -d "redirect_uri=https://yourapp.example.com/callback" \
  -d "client_id=app_8f2a1c0e9b" \
  -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
200 OK
{
  "access_token": "rdcp_at_9f2ac3d0e1b74a2f8c0d5b3a1e6f8c0d",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwaS5yZGNwYXNzLmNkIiwic3ViIjoiY2l0XzdmMWUyYTljNGIzZDhmNjAiLCJhdWQiOiJhcHBfOGYyYTFjMGU5YiIsImV4cCI6MTczNTY5MzIwMCwiaWF0IjoxNzM1Njg5NjAwfQ.signature",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile rdcpass:kyc.addresses rdcpass:kyc.phone_numbers rdcpass:kyc.emails rdcpass:kyc.documents.primary"
}

A successful exchange returns:

ParameterTypeDescription
access_tokenstringOpaque bearer token for calling /v1/oidc/userinfo. Not a JWT — don’t try to decode it.
id_tokenstring (JWT)A signed JWT containing the citizen’s identity claims. See ID token claims below.
token_typestringAlways "Bearer".
expires_innumberAccess token lifetime, in seconds.
scopestringThe scopes granted — identical to the scopes shown on the consent screen, since the citizen approves the request as a whole. A granted scope with no data on record is omitted from the claims rather than returned empty.

ID token claims

Decode and verify the id_token’s signature (see Verifying the id_token below) before trusting any claim inside it. Which claims appear depends on the scopes granted — this example was issued for "openid profile rdcpass:kyc.addresses rdcpass:kyc.phone_numbers rdcpass:kyc.emails rdcpass:kyc.documents.primary". File-bearing claims such as rdcpass:documents.primary are delivered by userinfo only (see below).

{
  "iss": "https://api.rdcpass.cd",
  "sub": "cit_7f1e2a9c4b3d8f60",
  "aud": "app_8f2a1c0e9b",
  "exp": 1735693200,
  "iat": 1735689600,
  "nonce": "n-0S6_WzA2Mj",
  "rdcpass_id": "COD-2103-0214-4937",
  "full_name": "Kabeya Mwamba Tshisekedi",
  "given_name": "Kabeya",
  "family_name": "Tshisekedi",
  "birthdate": "1988-04-12",
  "age": 38,
  "gender": "male",
  "nationality": "COD",
  "account_status": "active",
  "certified": true,
  "level_of_assurance": "LOA3",
  "account_created_at": "2025-03-14T09:22:41Z",
  "rdcpass:addresses": [
    {
      "province": "Kinshasa",
      "city": "Kinshasa",
      "commune": "Gombe",
      "quartier": "Batetela",
      "avenue": "Avenue du Commerce",
      "number": "12",
      "is_primary": true,
      "verified_at": "2025-03-14T09:40:12Z"
    }
  ],
  "rdcpass:phone_numbers": [
    { "number": "+243812345678", "operator": "Vodacom", "is_primary": true, "verified": true }
  ],
  "rdcpass:emails": [
    { "address": "kabeya.tshisekedi@example.cd", "is_primary": true, "verified": true }
  ]
}
ParameterTypeDescription
issstringAlways "https://api.rdcpass.cd".
substringA stable, per-citizen, per-application identifier. Deliberately opaque — it is not the citizen’s national identity document number, and does not change if that number is ever reissued.
audstringYour application’s API Key — always verify this matches your own client_id before trusting the token.
expnumberUnix timestamp the token expires at.
iatnumberUnix timestamp the token was issued at.
noncestringEchoes the nonce from the authorization request, if you sent one. Verify it matches.
rdcpass_idstringThe citizen’s RDCPASS ID, format COD-XXXX-XXXX-XXXX. Requires "profile".
full_namestringFull legal name as recorded by RDCPASS. Requires "profile".
given_namestringFirst name (basic KYC first_name). Requires "profile".
family_namestringLast name (basic KYC last_name). Requires "profile".
birthdatestringDate of birth, YYYY-MM-DD. Requires "profile".
ageintegerAge in whole years at issuance. Requires "profile".
genderstring"male" or "female". Requires "profile".
nationalitystringISO 3166-1 alpha-3 code, e.g. "COD". Requires "profile".
account_statusstring"active", "suspended", "paused" or "deleted". Requires "profile".
certifiedbooleantrue when the RDCPASS account is certified. Requires "profile".
level_of_assurancestring"LOA2", "LOA3" or "LOA4". Requires "profile".
account_created_atstringWhen the RDCPASS account was created (RFC 3339). Requires "profile".
rdcpass:*object | array | stringOne claim per additional KYC scope granted — see Scopes and claims below.

sub is a pairwise pseudonym, not the RDCPASS ID

sub is different for every application: two applications never receive the same sub for the same citizen, so it cannot be used to correlate a citizen across services. Use sub as your own database's foreign key — never parse it or expect a particular format. The citizen's RDCPASS ID is delivered separately, in the rdcpass_id claim, and only when the "profile" scope is granted. If you need a verified document number, request rdcpass:kyc.documents.primary or call KYC Validation.

Verifying the id_token

The id_token is a JWT signed with RS256. Fetch RDCPASS’s public keys from /v1/oidc/jwks.json — no authentication required — and verify the signature before trusting anything inside the token.

GET /v1/oidc/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-08-rsa-1",
      "n": "sXchd3fN0Vy0zGsFdX1...",
      "e": "AQAB"
    }
  ]
}
  1. Fetch and cache /v1/oidc/jwks.json (keys rotate infrequently; respect standard HTTP caching headers rather than fetching on every request).
  2. Verify the signature using the key matching the token’s "kid" header.
  3. Check iss equals "https://api.rdcpass.cd".
  4. Check aud equals your own client_id.
  5. Check exp hasn’t passed.
  6. If you sent a nonce, check it matches.

Standard JWT/JOSE libraries in every language handle all of this — jose (Node.js/Python), golang-jwt (Go), nimbus-jose-jwt (Java), jsonwebtoken (Rust’s jsonwebtoken crate), firebase/php-jwt (PHP), and System.IdentityModel.Tokens.Jwt (C#) all support fetching a JWKS URL and verifying RS256 directly.

Reading the profile again — /v1/oidc/userinfo

Call this endpoint with the access token as a bearer credential to read the full set of claims for the granted scopes — including the file-bearing claims (documents, biometrics) that are never embedded in the id_token. Document images and biometric files are returned as signed_url file objects, valid for 5 minutes: fetch them server-side and never store the URL.

GET /v1/oidc/userinfo HTTP/1.1
Host: api.rdcpass.cd
Authorization: Bearer rdcp_at_9f2ac3d0e1b74a2f8c0d5b3a1e6f8c0d
200 OK
{
  "sub": "cit_7f1e2a9c4b3d8f60",
  "rdcpass_id": "COD-2103-0214-4937",
  "full_name": "Kabeya Mwamba Tshisekedi",
  "given_name": "Kabeya",
  "family_name": "Tshisekedi",
  "birthdate": "1988-04-12",
  "age": 38,
  "gender": "male",
  "nationality": "COD",
  "account_status": "active",
  "certified": true,
  "level_of_assurance": "LOA3",
  "account_created_at": "2025-03-14T09:22:41Z",
  "rdcpass:addresses": [
    {
      "province": "Kinshasa",
      "city": "Kinshasa",
      "commune": "Gombe",
      "quartier": "Batetela",
      "avenue": "Avenue du Commerce",
      "number": "12",
      "is_primary": true,
      "verified_at": "2025-03-14T09:40:12Z"
    }
  ],
  "rdcpass:phone_numbers": [
    { "number": "+243812345678", "operator": "Vodacom", "is_primary": true, "verified": true }
  ],
  "rdcpass:emails": [
    { "address": "kabeya.tshisekedi@example.cd", "is_primary": true, "verified": true }
  ],
  "rdcpass:documents.primary": {
    "type": "passport",
    "number": "OB1234567",
    "issued_at": "2022-06-01",
    "expires_at": "2027-05-31",
    "issuing_authority": "Ministère des Affaires étrangères",
    "status": "valid",
    "image": {
      "content_type": "image/jpeg",
      "url": "https://files.rdcpass.cd/d/9f2c41e7ab03?sig=Q2hhbmdlTWU",
      "expires_at": "2026-09-26T10:20:02Z"
    }
  }
}

Scopes and claims

Login with RDCPASS uses the same KYC scopes as every other RDCPASS service. openid is always required; profile returns the basic KYC claims; each additional scope is requested as rdcpass:<scope> and returns one claim named after it. See KYC scopes & subscriptions for what each scope contains.

You can only request scopes granted to your application

An rdcpass: scope works only when your organization subscribes to it and it is selected on this application in the console — sensitive and biometric scopes additionally require enhanced due diligence. Requesting any other scope fails with invalid_scope, before the citizen is contacted. The consent screen shows exactly the scopes in your request, so ask only for what you need: every extra scope is one more line the citizen must accept.
ScopeClaimsTier
openidsub, iss, aud, exp, iat, nonceProtocol
profilerdcpass_id, full_name, given_name, family_name, birthdate, age, gender, nationality, account_status, certified, level_of_assurance, account_created_atBasic KYC
rdcpass:kyc.documents.primaryrdcpass:documents.primary(userinfo only)Standard
rdcpass:kyc.documents.allrdcpass:documents.all(userinfo only)Standard
rdcpass:kyc.addressesrdcpass:addressesStandard
rdcpass:kyc.emailsrdcpass:emailsStandard
rdcpass:kyc.phone_numbersrdcpass:phone_numbersStandard
rdcpass:kyc.professionsrdcpass:professionsStandard
rdcpass:kyc.place_of_birthrdcpass:place_of_birthStandard
rdcpass:kyc.marital_statusrdcpass:marital_statusStandard
rdcpass:kyc.languagesrdcpass:languagesStandard
rdcpass:kyc.religionrdcpass:religionSensitive
rdcpass:kyc.ethnicityrdcpass:ethnicitySensitive
rdcpass:kyc.biometrics.selfierdcpass:biometrics.selfie(userinfo only)Biometric
rdcpass:kyc.biometrics.fingerprintrdcpass:biometrics.fingerprint(userinfo only)Biometric
rdcpass:kyc.biometrics.irisrdcpass:biometrics.iris(userinfo only)Biometric
rdcpass:credit.scorerdcpass:credit.consentConsent

Credit-scoring consent — rdcpass:credit.score

Requesting rdcpass:credit.score asks the citizen to consent, on the same consent screen, to a credit score being computed about them. When approved, the id_token and userinfo carry a rdcpass:credit.consent claim; pass its value as the consent field of Credit Scoring. The consent is scoped to your application and expires at expires_at.

"rdcpass:credit.consent": {
  "method": "rdcpass_app",
  "consent_id": "cns_5e8a2d71c4",
  "expires_at": "2026-10-26T10:15:00Z"
}

OAuth error codes

These four endpoints use standard OAuth 2.0 error codes and shapes — NOT the error JSON body the rest of the API returns. An authorize-step error arrives as redirect query parameters (see Step 2 above); a token-step error arrives as a JSON body with the same field names, but returned with a 400 status instead of a redirect.

errorCause
invalid_requestA required parameter is missing, or a parameter has an invalid value — e.g. code_challenge_method wasn’t "S256".
invalid_clientclient_id doesn’t match a known application.
invalid_grantThe code is expired, already used, or the code_verifier doesn’t match the code_challenge from the authorize request.
unauthorized_clientLogin with RDCPASS isn’t enabled on this application. An Owner or Administrator can enable the service on the application in the console.
access_deniedThe citizen declined, failed the face-recognition check, or didn’t respond within 60 seconds — RDCPASS doesn’t distinguish which. Authorize-step only.
unsupported_response_typeresponse_type wasn’t "code". Authorize-step only.
invalid_scopeA requested scope doesn’t exist, isn’t granted to this application (not subscribed by your organization or not selected on the application), or "openid" was omitted. Authorize-step only — the citizen is never contacted.
server_errorAn internal error on RDCPASS’s side. Safe to retry.

Rate limits

These endpoints are metered under the sso_login service, subject to whatever sandbox or production quota your application is on. See Rate limits & quotas for current limits. Every other error condition not covered above — malformed mTLS, quota, etc. — uses the standard error format described in Errors.

Questions about your integration? Contact developer support