Authentication

Every request to an RDCPASS third-party API must satisfy three independent security layers at once — a valid HMAC-SHA256 signature, a client certificate presented over mutual TLS, and AES-256-GCM payload encryption. These are not alternative options — a request missing any one of them is rejected before it reaches identity services.

Required headers

The three layers below are enforced through a small set of custom headers, sent on every request:

HeaderSent byDescription
X-RDCPASS-Key-IdClientYour application's API key identifier. Not a secret — safe to appear in logs, comparable to an AWS Access Key ID. It tells RDCPASS which application's secret key to verify your signature against.
X-RDCPASS-TimestampClientUnix seconds at request time. Rejected with 401 if more than 5 minutes off server time.
X-RDCPASS-NonceClientAny unique string per request. A reused nonce is treated as a replay attack and rejected with 401.
X-RDCPASS-IVClient & serverBase64-encoded 12-byte AES-GCM initialization vector. The client sends one on the request; the server returns a fresh one on the response.
X-RDCPASS-SignatureClientHex-encoded HMAC-SHA256 signature over the request, computed as described below.

1. API key + HMAC-SHA256 request signing

X-RDCPASS-Key-Id identifies which application is calling — it's an identifier, not a secret, and it's safe to appear in logs (comparable to an AWS Access Key ID). It simply tells RDCPASS which application's secret key to verify your signature against.

The actual authentication is an HMAC-SHA256 signature over a canonical string:

method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body

body is the exact raw bytes sent on the wire — and because payload encryption also applies to every request (see section 3), that means the ciphertext bytes, not the plaintext. Encrypt the request body first, then sign the encrypted result. Signing the plaintext produces a signature the server can never reproduce, since it only ever sees the encrypted body.

The signature is hex-encoded and sent as X-RDCPASS-Signature. X-RDCPASS-Timestamp must be within 5 minutes of server time, and X-RDCPASS-Nonce must be unique per request — a reused nonce is rejected with 401 as a replay attempt.

sign_request.py
import hashlib
import hmac
import os
import time
import uuid

def sign_request(method: str, path: str, plaintext_body: bytes, key_id: str, secret_key: str, payload_key: bytes):
    # Payload encryption applies to every request. Encrypt FIRST, then sign
    # the resulting ciphertext — never sign the plaintext body.
    encrypted_body, iv_b64 = encrypt_payload(plaintext_body, payload_key)  # see AES-256-GCM below

    timestamp = str(int(time.time()))
    nonce = str(uuid.uuid4())

    # string_to_sign = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
    # "body" here is the exact bytes going on the wire — the ciphertext,
    # not the plaintext — concatenated as raw bytes, not re-encoded.
    prefix = f"{method.upper()}\n{path}\n{timestamp}\n{nonce}\n".encode("utf-8")
    string_to_sign = prefix + encrypted_body

    signature = hmac.new(
        secret_key.encode("utf-8"),
        string_to_sign,
        hashlib.sha256,
    ).hexdigest()

    return {
        "headers": {
            "X-RDCPASS-Key-Id": key_id,
            "X-RDCPASS-Timestamp": timestamp,
            "X-RDCPASS-Nonce": nonce,
            "X-RDCPASS-IV": iv_b64,
            "X-RDCPASS-Signature": signature,
        },
        "body": encrypted_body,
    }

2. mTLS client certificates

Every request must also present a client certificate signed by RDCPASS's third-party client CA. This is mutual TLS: RDCPASS authenticates to you via its server certificate as usual, and you authenticate to RDCPASS via a certificate it has issued, on every connection.

This matters because it's a possession-based credential layered on top of a knowledge-based one. An HMAC secret is a string — if it leaks, anyone who has it can compute valid signatures. A leaked secret alone is still useless without also possessing the matching private key and certificate, since the request never reaches the layer that checks the signature unless the mTLS handshake succeeds first.

How production certificates are issued

Once your production access is approved, generate a private key and a certificate signing request (CSR) on your own infrastructure and upload only the CSR in the console after approval. RDCPASS signs it and the certificate is bound to that application. The private key never leaves your servers. See Going to production for the full sequence.

The certificate and the HMAC key must belong to the same application — RDCPASS rejects any request where the mTLS client certificate and the X-RDCPASS-Key-Id resolve to different applications. This is deliberate: it stops a leaked HMAC secret from one application being combined with a different application's certificate to forge a valid-looking request.

3. AES-256-GCM payload encryption

Every request body is encrypted client-side with AES-256-GCM, using the application's Payload Encryption Key, before it is signed (section 1). The IV is 12 random bytes, base64-encoded, sent in the X-RDCPASS-IV header — it must be freshly generated for every request and never reused with the same key.

Responses are encrypted the same way: RDCPASS generates its own fresh IV per response, returned in its own X-RDCPASS-IV header, and encrypts the response body with the same Payload Encryption Key. Decrypt it the same way you encrypted the request.

encrypt_payload.py
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt_payload(plaintext: bytes, payload_key: bytes) -> tuple[bytes, str]:
    # 12 random bytes, generated fresh for every request. Never reuse an
    # IV with the same key — doing so breaks GCM's confidentiality guarantee.
    iv = os.urandom(12)

    aesgcm = AESGCM(payload_key)  # payload_key is 32 bytes (AES-256)
    ciphertext = aesgcm.encrypt(iv, plaintext, associated_data=None)
    # ciphertext already includes the 16-byte GCM auth tag appended —
    # this is the exact byte string that becomes the request body.

    return ciphertext, base64.b64encode(iv).decode("ascii")

def decrypt_response(ciphertext: bytes, iv_b64: str, payload_key: bytes) -> bytes:
    # The server encrypts its response the same way, with its own fresh IV
    # returned in the X-RDCPASS-IV response header.
    iv = base64.b64decode(iv_b64)
    aesgcm = AESGCM(payload_key)
    return aesgcm.decrypt(iv, ciphertext, associated_data=None)

Putting it together

A fully authenticated request carries all five headers above, with a ciphertext body:

Example request
POST /v1/kyc/validate HTTP/1.1
Host: api.rdcpass.gov
Content-Type: application/octet-stream
X-RDCPASS-Key-Id: key_live_9f2ac3d0e1b74a2f
X-RDCPASS-Timestamp: 1735297420
X-RDCPASS-Nonce: 5f3e2a1b-8c9d-4e6f-a1b2-3c4d5e6f7a8b
X-RDCPASS-IV: 8k2LpQz9vXbNc3Rt5gYwZQ==
X-RDCPASS-Signature: 3f7a9c2e1b8d4f6a0e5c7b9d2a4f6e8c1b3d5f7a9c0e2b4d6f8a1c3e5b7d9f0a

<AES-256-GCM ciphertext bytes — the encrypted, signed request body>
A request must pass all three checks — a valid HMAC signature, a valid client certificate bound to the same application, and, once decrypted, a well-formed request body — before it reaches RDCPASS's identity services. Any one failing on its own is enough for the request to be rejected.

Questions about your integration? Contact developer support