ABDM Fidelius Encryption in Node.js (2026): ECDH, HKDF and AES-GCM with the Correct Curve25519 Parameters
CTO & Co-Founder
CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

ABDM Fidelius encryption is the scheme every HIP must use to encrypt health records before pushing them to the requester: an ECDH key agreement on Curve25519 in BouncyCastle's short Weierstrass form, HKDF-SHA256 to derive a key, and AES-256-GCM to encrypt the FHIR bundle. Most failed implementations outside Java fail for one reason: they use the wrong curve parameters or the Montgomery form X25519, and ABDM answers with ABDM-9999: Could not read encrypted content.
This guide explains the Fidelius protocol step by step, shows exactly where HIP encryption goes wrong, and gives a working Node.js implementation with the correct parameters, a way to prove them, the published test vectors, and the data push format with its common mistakes. It comes from our own ports of Fidelius to TypeScript and Go, both tested round-trip and against live sandbox data. Updated September 2026.
The error every ABDM integrator hits
You have built your HIP. Discovery works, linking works, consent arrives. Then you push the first health record and the requester cannot read it:
ABDM-9999: Could not read encrypted content
encoded key spec not recognized ABDM requires every health record in transit to be Fidelius-encrypted, including in the sandbox. There is no plain-text or debug mode. If your encryption is even slightly different from the reference, the record is unreadable. If you are still building the HIP itself, start with our ABDM HIP reference architecture; if you want the whole M2 integration handled, our ABDM integration services team builds and certifies it.
What the Fidelius protocol does, step by step
Fidelius gives every transfer fresh keys (forward secrecy): the requester (HIU) and the provider (HIP) each generate a new keypair and nonce, agree a shared secret, and derive the encryption key from it. Neither side sends the key itself.
| Step | What happens | Detail |
|---|---|---|
| 1. Key generation | Each side makes an ECDH keypair | Curve25519 in short Weierstrass form (BouncyCastle), not Montgomery X25519 |
| 2. Nonce | Each side makes a 32-byte random nonce | Sent alongside the public key |
| 3. Nonce processing | XOR the two nonces | First 20 bytes become the HKDF salt, last 12 bytes the AES-GCM IV |
| 4. Shared secret | ECDH: own private key times the other side's public key | Use the x-coordinate of the result (32 bytes) |
| 5. Key derivation | HKDF-SHA256(salt, shared secret) | 32-byte AES key, empty info |
| 6. Encryption | AES-256-GCM(key, IV, FHIR JSON) | Output base64(ciphertext followed by the 16-byte tag) |
The HIU generates its keypair and sends only its public key and nonce with the health information request. The HIP generates its own, encrypts, and returns its public key and nonce with the data. Both sides derive the same key independently. On the HIU side, keys must be kept only as long as the consent allows and deleted when it is revoked or expires, which is also an M3 test case.
The Curve25519 parameter trap in BouncyCastle
This is where most non-Java implementations fail. ABDM's reference uses BouncyCastle's Curve25519 in short Weierstrass form. When developers look up the parameters, they often find values quoted in code comments and blog posts that are not the curve's a and b at all.
| Parameter | Correct value (from Curve25519.java) |
|---|---|
| p | 2^255 - 19 |
| a | 2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144 |
| b | 7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864 |
| n (order) | 1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED |
| G.x | 2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD245A |
| G.y | 20AE19A1B8A086B4E01EDD2C7748D14C923D4D7E6D7C61B229E9C5A27ECED3D9 |
| h (cofactor) | 8 |
The source is BouncyCastle's own implementation class, Curve25519.java. Commonly copied values such as a = 2aaa...aad2451 and b = 20ae19a1...27eced3d9 are wrong: with them, the generator point does not satisfy the curve equation.
Prove your parameters in ten lines
Before you write any encryption code, check that the generator lies on your curve. If this prints False, nothing downstream will work.
p = 2**255 - 19
a = 0x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144
b = 0x7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864
Gx = 0x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD245A
Gy = 0x20AE19A1B8A086B4E01EDD2C7748D14C923D4D7E6D7C61B229E9C5A27ECED3D9
# The point is on the curve if y^2 = x^3 + a*x + b (mod p)
print((Gy * Gy - (Gx**3 + a * Gx + b)) % p == 0) # True A working Fidelius implementation in Node.js
You do not need Java, a subprocess or a JVM. About 200 lines of TypeScript using the elliptic library for the curve and Node's built-in crypto for HKDF and AES-GCM are enough.
Curve setup
import elliptic from 'elliptic'
// BouncyCastle Curve25519, short Weierstrass form (not X25519)
export const bc25519 = new (elliptic.curve.short as any)({
p: '7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed',
a: '2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144',
b: '7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864',
n: '1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed',
g: [
'2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad245a',
'20ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9',
],
h: '08',
}) Encrypt (HIP side)
import crypto from 'node:crypto'
import BN from 'bn.js'
export function encrypt(opts: {
senderPrivateKey: Buffer // 32-byte scalar
senderNonce: Buffer // 32 bytes
requesterPublicPoint: any // elliptic point decoded from the HIU's keyValue
requesterNonce: Buffer // 32 bytes
plaintext: string // FHIR bundle JSON
}): string {
// XOR nonces: salt = first 20 bytes, IV = last 12 bytes
const xor = Buffer.alloc(32)
for (let i = 0; i < 32; i++) xor[i] = opts.senderNonce[i] ^ opts.requesterNonce[i]
const salt = xor.subarray(0, 20)
const iv = xor.subarray(20, 32)
// ECDH: x-coordinate of (own private scalar x requester public point)
const priv = new BN(opts.senderPrivateKey)
const shared = Buffer.from(opts.requesterPublicPoint.mul(priv).getX().toArray('be', 32))
// HKDF-SHA256 -> 32-byte AES key
const key = Buffer.from(crypto.hkdfSync('sha256', shared, salt, Buffer.alloc(0), 32))
// AES-256-GCM, output base64(ciphertext || tag)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(opts.plaintext, 'utf8'), cipher.final()])
return Buffer.concat([ct, cipher.getAuthTag()]).toString('base64')
} Decrypt (HIU side)
export function decrypt(opts: {
receiverPrivateKey: Buffer; receiverNonce: Buffer
senderPublicPoint: any; senderNonce: Buffer; data: string
}): string {
const xor = Buffer.alloc(32)
for (let i = 0; i < 32; i++) xor[i] = opts.senderNonce[i] ^ opts.receiverNonce[i]
const shared = Buffer.from(opts.senderPublicPoint.mul(new BN(opts.receiverPrivateKey)).getX().toArray('be', 32))
const key = Buffer.from(crypto.hkdfSync('sha256', shared, xor.subarray(0, 20), Buffer.alloc(0), 32))
const raw = Buffer.from(opts.data, 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm', key, xor.subarray(20, 32))
decipher.setAuthTag(raw.subarray(raw.length - 16))
return Buffer.concat([decipher.update(raw.subarray(0, raw.length - 16)), decipher.final()]).toString('utf8')
} The public key you send is not the raw point. In the keyMaterial it is an X.509 DER-encoded public key (SubjectPublicKeyInfo), base64-encoded. Compare your encoding byte for byte with a key produced by the Java reference before testing against the sandbox.
Validate with the published test vectors
The Java reference, mgrmtech/fidelius-cli, publishes test vectors. Run all of them through your encrypt and decrypt before touching the sandbox. One of them:
Private key: DMxHPri8d7IT23KgLk281zZenMfVHSdeamq0RhwlIBk=
Plaintext: "Wormtail should never have been Potter cottage's secret keeper."
Expected: pzMvVZNNVtJzqPkkxcCbBUWgDEBy/mBXIeT2dJWI16ZA...
Result: MATCH (all 10 test vectors pass) Passing the vectors proves the curve, the nonce handling, HKDF and AES-GCM together. Then add a round-trip test (your encrypt, your decrypt) to your build so a library upgrade can never silently break it.
The data push format and seven common mistakes
After encryption, the HIP pushes the entries to the requester's dataPushUrl with its own key material. The shape that works:
{
"pageNumber": 1,
"pageCount": 1,
"transactionId": "<transactionId from the request, top level>",
"entries": [{
"content": "<base64 Fidelius ciphertext of the FHIR bundle>",
"media": "application/fhir+json",
"checksum": "<checksum of the content>",
"careContextReference": "<the care context reference you linked>"
}],
"keyMaterial": {
"cryptoAlg": "ECDH",
"curve": "Curve25519",
"dhPublicKey": {
"expiry": "<ISO timestamp>",
"parameters": "Curve25519/32byte random key",
"keyValue": "<HIP public key, X.509 DER, base64>"
},
"nonce": "<HIP nonce, base64>"
}
} - Sending the requester's keyMaterial instead of the HIP's own public key and nonce.
- Using a database UUID as careContextReference instead of the care context reference you linked.
- Sending plain base64 FHIR instead of Fidelius-encrypted content.
- Using the raw uncompressed EC point instead of the X.509 DER format for
keyValue. - Using a SHA-256 checksum where ABDM expects MD5, as it did in our sandbox runs.
- Adding gateway headers (REQUEST-ID, X-CM-ID) to the dataPushUrl call; only Authorization and Content-Type are needed.
- Reading transactionId from the hiRequest object when it sits at the top level of the request body.
Fidelius implementations by language
| Language | What to use | The trap |
|---|---|---|
| Java, .NET | BouncyCastle with the custom curve | Mixing BouncyCastle's curve with a different KDF or IV split |
| Node.js, TypeScript | elliptic custom short curve + node:crypto | Using crypto.diffieHellman with X25519 |
| Python | pyfidelius or custom curve arithmetic | Trusting a port without checking its parameters |
| Go | Custom Weierstrass arithmetic | crypto/ecdh X25519 is Montgomery form and will not interoperate |
| PHP, Ruby, others | Port the math and the test vectors | Same parameter trap in every language |
How we run Fidelius in production-grade integrations
We have ported Fidelius to TypeScript and Go for our ABDM integrations, both with round-trip tests in the build, and use it on both sides of the exchange: encrypting as a HIP and decrypting as a HIU. In a live test against the ABDM sandbox in September 2026, every value in every test record decrypted and displayed on the requesting side: 526 of 526 values and 30 of 30 attachments. What made that reliable:
- Parameters proved against the curve equation and the test vectors before any sandbox call.
- One keypair and nonce per transfer, stored with the data request it belongs to, and deleted when the consent is revoked or expires.
- On the HIU side, the keys for each data request are stored with that request, so every push can be decrypted with the keypair it was encrypted for.
- Encrypted bundles checked for display after decryption: a record that decrypts but shows NA is a bundle problem, covered in our guide to NRCeS FHIR profiles and NA fields.
Related reading: the full ABDM integration step-by-step guide, the ABDM HIU reference architecture for the decrypting side, and 25 common ABDM errors and fixes. Other references: the NRCeS FHIR IG for the records you encrypt.
Stuck on ABDM-9999 or a data push that never decrypts? Talk to our ABDM team; we can review your implementation against the test vectors and the sandbox.
Ready to scale?
Talk to our healthcare engineering team about building, integrating, and shipping faster.
Frequently Asked Questions
What is ABDM Fidelius encryption?
Can I skip Fidelius encryption in the ABDM sandbox?
Why do most non-Java Fidelius implementations fail?
Is there a native Node.js Fidelius implementation?
What is the difference between X25519 and BouncyCastle Curve25519?
How do I validate my Fidelius implementation?
What does ABDM-9999 Could not read encrypted content mean?


