Table of Contents
- Why PIN encryption matters
- Core terms defined
- How RSA works
- Anatomy of a key pair
- The OpenSSLUtil class, line by line
- The full encrypt → transmit → decrypt flow
- Working with keys in the terminal
- Common pitfalls & security notes
- Conclusion
1. Why PIN encryption matters
Every time a user taps their 4-digit PIN into a mobile banking app or payment terminal, those four digits travel from device to server. If they travel as plain text — say, {"pin": "1234"} — anyone who can intercept the HTTP request, peek at server logs, or read a database dump will see that PIN immediately. That is a catastrophic failure mode.
The solution is asymmetric encryption. The server holds a private key that it never shares. The client has a copy of the corresponding public key. The client encrypts the PIN before it leaves the device, and only the server — with its private key — can decrypt it. No intermediary, no log, no network sniffer can read the PIN in transit.
The code snippet in this article is a production-ready implementation of this pattern, built on Node.js's built-in crypto module using RSA-OAEP encryption with SHA-256 — the modern, recommended approach. This article walks through every part of it, explaining the concepts before the code.
2. Core terms defined
Before touching a single line of code, you need a working vocabulary. These terms will come up again and again.
| Term | Definition |
|---|---|
| Plaintext | The original readable data before any encryption. In our case, the PIN — e.g. the string "1234". |
| Ciphertext | The scrambled output after encryption. Looks like random bytes. Example: a 256-byte binary blob that encodes to a long base64 string. |
| Encryption | The process of transforming plaintext into ciphertext using a key and an algorithm. Without the correct decryption key, ciphertext is meaningless. |
| Decryption | The reverse: transforming ciphertext back into plaintext using the correct key. |
| RSA | Rivest–Shamir–Adleman. A widely used asymmetric encryption algorithm based on the mathematical difficulty of factoring very large numbers. |
| Asymmetric encryption | A scheme that uses two different keys: a public key to encrypt, and a private key to decrypt. You can freely share the public key — knowing it does not help you decrypt. |
| Symmetric encryption | A scheme that uses the same key for both encryption and decryption (e.g. AES). Fast, but the key must be shared securely — which is the hard part. |
| Public key | One half of the RSA key pair. Safe to share with anyone. Used to encrypt data or verify signatures. |
| Private key | The other half. Must be kept secret. Used to decrypt data or create signatures. If an attacker gets this, all security is lost. |
| OAEP padding | Optimal Asymmetric Encryption Padding. The modern, secure padding scheme for RSA. Uses a hash function (SHA-256 in this codebase) to produce randomised, tamper-resistant ciphertext. Resistant to padding oracle attacks. |
| PKCS#8 | A standard format for encoding private keys. Node.js v17+ requires private keys to be in PKCS#8 format (begins with -----BEGIN PRIVATE KEY-----). |
| PEM format | Privacy Enhanced Mail. A base64-encoded wrapper for certificates and keys with a human-readable header and footer. The -----BEGIN ...----- / -----END ...----- lines you see in key files. |
| Base64 | An encoding (not encryption!) that converts binary data into printable ASCII characters. Used to safely include binary ciphertext in JSON or URL parameters. |
| Digital signature | A cryptographic proof that a message was created by the holder of a private key and has not been tampered with. Created with the private key; verified with the public key. |
| Buffer | Node.js's representation of raw binary data. Crypto operations work on Buffers, not JavaScript strings. |
| SHA-256 | A cryptographic hash function that produces a fixed 256-bit digest of any input. Used both in OAEP padding and in the signing algorithm. |
3. How RSA works
You do not need to understand the mathematics in depth, but a conceptual model will prevent a lot of confusion later.
RSA relies on a simple fact: multiplying two large prime numbers together is easy, but factoring the result back into those two primes is computationally infeasible. The public and private keys are derived from this relationship — one cannot be practically computed from the other.
Encryption with the public key:
- Anyone can encrypt a message
- Freely distribute your public key
- OAEP padding makes output non-deterministic
- Only the private key holder can read it
Decryption with the private key:
- Only the key holder can decrypt
- Private key never leaves the server
- Decryption always yields the same original plaintext
- Losing the private key = losing access
A common analogy: the public key is like an open padlock you hand to everyone. Anyone can snap it shut (encrypt). Only you have the physical key that opens it (decrypt).
Note — RSA is not for bulk data
RSA can only encrypt data smaller than its key size minus padding overhead. A 2048-bit key with OAEP-SHA256 can encrypt at most ~190 bytes of data. This is perfect for short secrets like PINs, passwords, or symmetric keys — but you would never RSA-encrypt a file. For large data, RSA is used to encrypt a random AES key, then AES encrypts the actual data. This hybrid approach is what TLS does.
4. Anatomy of a key pair
The example gives you a 2048-bit RSA key pair. Let's understand what you are looking at.
The public key
Safe to share, embed in your mobile app, or publish in your documentation. It begins with:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnFBPODvVUdzXxGkXG0OP
HQ0zDS3xf1uD7tY8mJKWrjSLMf2CeZtWYNlT2xq40R7x5VTn1rnKbgnBZg2ynyLh...
-----END PUBLIC KEY-----
This is PEM format — the binary key data encoded in base64, wrapped with header and footer lines. The actual key material is the base64 between the markers.
The private key (PKCS#8)
Never share this. It begins with -----BEGIN PRIVATE KEY----- (not BEGIN RSA PRIVATE KEY, which is the older PKCS#1 format). Node.js v17+ requires PKCS#8. This is why the setup guide includes a conversion command:
# Convert PKCS#1 to PKCS#8 (Node.js v17+ requires this)
openssl pkcs8 -topk8 -nocrypt -in old_private.pem -out new_private_pkcs8.pem
Key format summary
| Header line | Format | Used for |
|---|---|---|
BEGIN RSA PRIVATE KEY |
PKCS#1 | Older OpenSSL tools. Not accepted by Node.js v17+. |
BEGIN PRIVATE KEY |
PKCS#8 | Modern standard. Required by Node.js createPrivateKey. |
BEGIN ENCRYPTED PRIVATE KEY |
PKCS#8 encrypted | Private key protected with a passphrase. |
BEGIN PUBLIC KEY |
X.509 SubjectPublicKeyInfo | Standard public key format. |
5. The OpenSSLUtil class, line by line
Now let's walk through the implementation. The class has three main responsibilities: loading the key pair, encrypting with the public key, and decrypting with the private key. There is also a bonus signing method.
Constructor and key loading
class OpenSSLUtil {
constructor() {
this.keyPair = this._readKeyPair();
// Key parsing happens once at startup — not on every request.
}
_readKeyPair() {
const { privateKey: privatePem, passphrase } = config.rsaConfig;
const privateKey = crypto.createPrivateKey({
key: privatePem,
format: 'pem',
...(passphrase && { passphrase })
});
// Derive the public key from the private key — no need to store both
const publicKey = crypto.createPublicKey(privateKey);
return { privateKey, publicKey };
}
}
Why derive the public key from the private key?
A private key mathematically contains all the information needed to reconstruct the public key. Calling
crypto.createPublicKey(privateKey)is safe, convenient, and avoids having to store or sync two separate key files. You only need to protect the private key.
Encrypt method — OAEP-SHA256
encrypt(text) {
try {
const encrypted = crypto.publicEncrypt(
{
key: this.keyPair.publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
// OAEP with SHA-256: secure, modern, tamper-resistant
},
Buffer.from(text) // convert string → binary Buffer
);
return encrypted.toString('base64'); // binary → base64 string
} catch (e) {
logger.error('Could not encrypt data: ', e);
return null;
}
}
Breaking this down step by step:
-
Buffer.from(text)— Converts the string"1234"into raw bytes. RSA encryption operates on binary data, not JavaScript strings. -
RSA_PKCS1_OAEP_PADDING— Applies OAEP padding using SHA-256 as the hash function. OAEP uses a random seed internally, so encrypting the same PIN twice produces completely different ciphertexts — making brute-force and rainbow-table attacks impractical. -
oaepHash: 'sha256'— Specifies SHA-256 as the hash used inside the OAEP padding scheme. This must match on both encrypt and decrypt. -
.toString('base64')— Encodes the binary output as a base64 string, making it safe to include in JSON request bodies or HTTP headers.
Decrypt method — OAEP-SHA256
decrypt(encrypted) {
try {
// Accept either a base64 string or a raw Buffer
const buffer = typeof encrypted === 'string'
? Buffer.from(encrypted, 'base64') // base64 string → binary Buffer
: encrypted; // already a Buffer, use as-is
const decrypted = crypto.privateDecrypt(
{
key: this.keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256' // must match what was used in encrypt()
},
buffer
);
return decrypted.toString('utf8'); // binary → original string
} catch (e) {
logger.error('Could not decrypt data: ', e);
return null;
}
}
Warning — Padding scheme and hash must match on both ends
Both
encrypt()anddecrypt()useRSA_PKCS1_OAEP_PADDINGwithoaepHash: 'sha256'. If you encrypt with one hash and attempt to decrypt with another, decryption will silently fail and returnnull. Always keep these parameters consistent across your entire stack — including any client-side or terminal-based encryption.
Sign method
Signing is separate from encryption. A signature does not hide the content — it proves authorship and integrity. You sign with your private key; anyone with your public key can verify the signature.
sign(text) {
try {
const sign = crypto.createSign('SHA256');
// 1. Feed the data into the sign object
sign.update(text, 'utf8');
sign.end();
// 2. Finalize: hash the data with SHA-256, then RSA-sign the hash
return sign.sign(this.keyPair.privateKey, 'base64');
} catch (e) {
logger.error('Could not sign data: ', e);
return null;
}
}
The signing algorithm does two things internally: it first runs SHA256(text) to produce a 32-byte digest, then RSA-encrypts that digest with the private key. The result is a 256-byte (2048-bit) signature, returned as base64.
Module export as singleton
// Export a single shared instance (not the class itself)
module.exports = new OpenSSLUtil();
// Usage elsewhere in the codebase:
const openssl = require('./utils/openssl-util');
const encryptedPin = openssl.encrypt('1234');
const recoveredPin = openssl.decrypt(encryptedPin);
Exporting new OpenSSLUtil() rather than the class itself means the key parsing happens once at startup. Every module that requires this file gets the same instance with the already-loaded keys — no repeated I/O or parsing overhead per request.
6. The full encrypt → transmit → decrypt flow
Here is the complete lifecycle of a PIN from a client device to a server response, as the codebase implements it.
Client has public key
↓
User enters PIN
↓
Encrypt with public key (OAEP-SHA256)
↓
Base64 encode
↓
HTTPS POST
↓
Server decrypts with private key
↓
Plaintext PIN
Step 1 — Client encrypts the PIN (terminal simulation)
# Encrypt "1234" using OAEP with SHA-256 and output as base64
echo -n "1234" | openssl pkeyutl -encrypt -pubin -inkey public.pem \
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 | base64
# Output:
mK9xZq2Tp7fVnBsLuR4dAe8cWyOhXiGj...
# Run again — you'll get a completely different string (OAEP is non-deterministic)
Step 2 — Client sends the encrypted PIN
{
"pin": "mK9xZq2Tp7fVnBsLuR4dAe8cWyOhXiGj..."
}
Why is this safe even over plain HTTP?
Even if an attacker intercepts this JSON, they see only the base64 ciphertext — not the PIN. Without the private key (which never leaves the server), they cannot decrypt it. That said, you should still use HTTPS. Defence in depth means multiple layers of protection.
Step 3 — Server decrypts and verifies
const openssl = require('../../utils/openssl-util');
router.post('/', async (req, res) => {
const { username, pin } = req.body;
// Decrypt the incoming base64 string back to plaintext
const decryptedPin = openssl.decrypt(pin);
if (!decryptedPin) {
return res.status(400).json({ error: 'Invalid encrypted PIN' });
}
// Never store the raw PIN — hash it before comparison
const pinHash = crypto
.createHash('sha256')
.update(decryptedPin)
.digest('hex');
// Compare hash against what is stored in the database
const user = await findUserByPhone(phonenumber);
if (!user || user.pinHash !== pinHash) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateSessionToken(user.id);
res.json({ token });
});
7. Working with keys in the terminal
The provided setup guide includes several OpenSSL terminal commands. Here is what each one does and when to use it.
Generate a fresh key pair
# Generate a 2048-bit RSA private key and immediately convert to PKCS#8
openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out private_pkcs8.pem
# Derive the public key from the private key
openssl rsa -in private_pkcs8.pem -pubout -out public.pem
Encrypt a PIN for testing (OAEP)
# Encrypt "1234" using OAEP with SHA-256 and output as base64
echo -n "1234" | openssl pkeyutl -encrypt -pubin -inkey public.pem \
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 | base64
# Verify round-trip: decrypt the output back to the original PIN
echo "mK9xZq2..." | base64 -d | openssl pkeyutl -decrypt \
-inkey private_pkcs8.pem -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256
# Output: 1234
Convert the key to a JSON-safe escaped string
Config files like config.json cannot store multi-line strings. The private key PEM has newlines, so you need to escape them as \n.
# Node.js method
node -e "console.log(require('fs').readFileSync('private_pkcs8.pem','utf8').replace(/\n/g,'\\n'))"
# Python method (often more reliable across platforms)
python3 -c "print(open('private_pkcs8.pem').read().replace('\n', '\\n'))"
# awk method
awk 'NF {printf "%s\\n", $0} END {print ""}' private_pkcs8.pem
The resulting single-line string can be pasted directly into your config:
{
"config": {
"privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEA...\n-----END PRIVATE KEY-----\n"
}
}
Warning — Never commit private keys to version control
The private key in this article is a test key. Never commit a real private key to a git repository, even a private one. Store keys in environment variables, a secrets manager (AWS Secrets Manager, HashiCorp Vault), or an encrypted config service.
8. Common pitfalls & security notes
Don't log decrypted PINs
The catch blocks in the utility log errors. Never add a console.log(decryptedPin) for debugging. Application logs often end up in log aggregation platforms, and PINs in plaintext in logs are a compliance violation.
Singleton means shared state
The module exports new OpenSSLUtil() — a singleton. The key pair is shared across all requests. This is fine if keys are read-only after initialization. But if you ever implement key rotation, you need to handle the transition carefully to avoid decryption failures on in-flight requests.
OAEP encryption is non-deterministic — that's a feature
Encrypting "1234" twice will produce two completely different ciphertexts. OAEP padding incorporates a random seed on every operation. This means an attacker cannot use a pre-computed rainbow table to map ciphertexts back to PINs, and cannot tell whether two encrypted values are the same PIN. Each encryption is cryptographically unique.
Encryption vs signing — knowing when to use each
| Scenario | Use | Why |
|---|---|---|
| Sending a PIN from client to server | Encrypt (public key, OAEP) | Keeps PIN confidential in transit |
| Server proves a response came from it | Sign (private key) | Client can verify with public key |
| Client proves a request came from it | Sign with client's private key | Requires client-side key management |
| Storing a PIN for comparison | Hash (bcrypt / SHA-256) | One-way — even server can't recover plaintext |
9. Conclusion
PIN encryption with RSA-OAEP in Node.js is a well-trodden pattern that, when implemented correctly, provides strong guarantees: a PIN that leaves a client device is unreadable to anyone who intercepts it, because only the server's private key can unlock it.
The key ideas to carry forward are:
- Asymmetric encryption solves the key-distribution problem. Share the public key freely; guard the private key absolutely.
- OAEP with SHA-256 is the modern, secure padding choice. It is non-deterministic, tamper-resistant, and immune to padding oracle attacks — use it for all new RSA encryption.
- Buffer ↔ string conversions are explicit in Node.js. Always know whether you are working with binary data or a string, and which encoding (base64, utf8, hex) bridges them.
-
PKCS#8 is the modern format. If you see
BEGIN RSA PRIVATE KEY, convert it before passing it to Node.js v17+. - Encryption is not storage. After decrypting a PIN on the server, hash it before comparing to the stored value. RSA encryption is for transit security, not at-rest security.
From here, your natural next steps are implementing key rotation, considering hybrid encryption (RSA wrapping an AES key) for any payloads larger than a short PIN, and exploring the browser's WebCrypto API for client-side encryption using the same OAEP scheme.
Top comments (0)