Cryptography in NodeJS
Introduction
Almost everything in backend security reduces to a few primitives:
- Hashing
- Encryption
- Digital Signatures
Once these click, JWTs, TLS, OAuth, webhooks, cookies, API auth, and federated identity become much easier to reason about.
Hashing
Goal
Hashing answers:
“Can I create a unique fingerprint for this data?”
A hash function:
plaininput -> fixed-size output
Example:
plain"hello" -> X123ABC
Properties:
- deterministic
- one-way
- tiny input change → huge output change
- not reversible
Mental Model
Server A sends:
plainpay user $100
Server B hashes it:
plainHASH(pay user $100) -> A1B2C3
Later if somebody changes:
plainpay user $900
Then:
plainHASH(pay user $900) -> Z9Y8X7
Hash changes completely.
That’s why hashing is useful for integrity.
Fake Toy Math
Real hashes are complex.
But imagine:
plainHASH(message) = total character count × 7
So:
plain"hello" 5 × 7 = 35
Again:
- fake
- insecure
- intuition only
Real hashes like SHA-256 are mathematically designed to make reversing practically impossible.
Node.js Example
Using Node's built-in crypto module.
javascriptconst crypto = require('crypto'); const message = 'pay user $100'; const hash = crypto .createHash('sha256') .update(message) .digest('hex'); console.log(hash);
Password Hashing
Passwords are usually hashed, not encrypted.
Because:
plainwe should verify passwords not decrypt passwords
Libraries like bcrypt and Argon2 are designed specifically for password hashing.
Simple Argon2 example:
bashnpm install argon2
javascriptconst argon2 = require('argon2'); const password = 'my-password'; const hash = await argon2.hash(password); const valid = await argon2.verify(hash, password); console.log(valid);
Notice:
- no decrypt
- no plaintext recovery
- only verification
Hashing Security in the Real World
Hashing algorithms themselves are public and standardized — security does not come from hiding the algorithm, but from the computational difficulty of reversing or brute-forcing hashes. Modern cryptography assumes attackers already know the algorithm.
Fast hashes like SHA-256 are excellent for integrity checks and signatures, but are intentionally too fast for password storage because GPUs and ASICs can test billions of guesses per second in 2026.
Password hashing algorithms like bcrypt and Argon2 are deliberately slow and memory-intensive, making large-scale cracking dramatically more expensive in hardware, electricity, and time. Argon2 is generally considered the modern recommendation because it is designed to resist both GPU and ASIC acceleration effectively.
Encryption
Goal
Encryption answers:
“How do I hide data from unauthorized readers?”
Flow:
plainplaintext -> ciphertext -> plaintext
Unlike hashing:
plainencryption is reversible
Symmetric Encryption
Core Idea
Same secret key:
plainencrypt(secret) decrypt(secret)
Both servers must know the same secret.
Mental Model
Server A and Server B both know:
plainSECRET_KEY = abc123
Server A encrypts:
plain"database-password"
Server B decrypts using the same secret.
Fake Toy Math
Suppose:
plainsecret key = +5
Encrypt:
plain10 + 5 = 15
Decrypt:
plain15 - 5 = 10
Same secret used both ways.
Again:
- fake
- intuition only
Node.js Example
Using a beginner-friendly library.
bashnpm install crypto-js
javascriptconst CryptoJS = require('crypto-js'); const secret = 'shared-secret'; const message = 'hello from server A'; // ENCRYPT const encrypted = CryptoJS.AES .encrypt(message, secret) .toString(); console.log(encrypted); // DECRYPT const bytes = CryptoJS.AES.decrypt(encrypted, secret); const decrypted = bytes.toString(CryptoJS.enc.Utf8); console.log(decrypted);
Symmetric Encryption Problem
Main issue:
“How do both servers securely share the secret key initially?”
This is exactly the problem asymmetric cryptography helps solve.
Asymmetric Encryption
Core Idea
Two mathematically related keys:
plainpublic key private key
Rule:
plainencrypt(public) decrypt(private)
Mental Model
Server B generates:
plain(publicKey, privateKey)
Server B:
- shares public key openly
- keeps private key secret
Now Server A can encrypt data using the public key.
Only Server B can decrypt it.
Fake Toy Math
Suppose:
plainpublic operation = +7 private operation = -7
Server A encrypts:
plain10 + 7 = 17
Server B decrypts:
plain17 - 7 = 10
Again:
- fake
- intuition only
Real systems like RSA use:
- large primes
- modular arithmetic
- one-way mathematical relationships
Important Insight
The key pair is generated together.
That’s why:
plainpublic key and private key match
They are mathematically linked.
But:
plainpublic key -> cannot practically derive private key
That asymmetry is the foundation.
Node.js Example
Using Node's built-in crypto module.
javascriptconst crypto = require('crypto'); const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, }); const message = 'hello from server A'; // ENCRYPT USING PUBLIC KEY const encrypted = crypto.publicEncrypt( publicKey, Buffer.from(message) ); // DECRYPT USING PRIVATE KEY const decrypted = crypto.privateDecrypt( privateKey, encrypted ); console.log(decrypted.toString());
Real-World Usage
Asymmetric crypto is usually used for:
- establishing trust
- securely exchanging secrets
- authentication
- HTTPS handshakes
Not bulk data encryption.
Why?
Because asymmetric crypto is slower.
Real systems usually:
- use asymmetric crypto to establish trust
- exchange a symmetric session key
- use fast symmetric encryption afterward
HTTPS works this way.
Digital Signatures
This is where hashing and asymmetric crypto combine.
Goal
Digital signatures answer:
“How do I prove this message came from the real sender and was not modified?”
Notice:
plainsignatures are NOT about secrecy
The message often remains readable.
Signature Flow
plainmessage -> hash -> sign hash using private key -> signature
Receiver:
plainmessage + signature -> verify using public key -> valid / invalid
Mental Model
Server B owns:
plainpublic key private key
Server B sends:
plain"pay user $100"
But also attaches:
plainsignature
Now Server A can verify:
- message really came from Server B
- nobody changed the contents
Why Hash First?
Instead of signing huge data, we hash it first and then sign the hash.
plainsign(hash(data))
This is:
- faster
- more efficient
- standard practice
Fake Toy Math
Suppose:
plainprivate signing operation = ×3 public verify operation = ÷3
Hash:
plain5
Sign:
plain5 × 3 = 15
Signature:
plain15
Verifier checks:
plain15 ÷ 3 = 5
Matches original hash.
Result:
plainVALID
Again:
- fake
- intuition only
The Direction Reversal
This is one of the most important ideas.
Encryption:
plainencrypt(public) decrypt(private)
Because:
plainonly owner should read
Signatures:
plainsign(private) verify(public)
Because:
plainonly owner should claim authorship
That’s why the directions flip.
Node.js Example
javascriptconst crypto = require('crypto'); const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, }); const message = 'pay user $100'; // SIGN // 1. hash(message) using SHA-256 // 2. sign(hash) using private key // 3. return signature const signature = crypto.sign( 'sha256', Buffer.from(message), privateKey ); // VERIFY // 1. hash(received message) again // 2. verify signature using public key // 3. compare hashes // 4. return true/false const valid = crypto.verify( 'sha256', Buffer.from(message), publicKey, signature ); console.log(valid);
Big Picture
Most backend security systems are combinations of these primitives.
| Goal | Primitive |
|---|---|
| Create fingerprint | Hashing |
| Hide data | Encryption |
| Share secret securely | Asymmetric crypto |
| Encrypt efficiently | Symmetric crypto |
| Prove authenticity | Digital Signatures |
| Detect tampering | Hashing + Signatures |
To recap before we dive into JWT.
Encryption / Decryption
plainPlain Data + Public Key │ ▼ E(data, publicKey) │ ▼ Ciphertext │ ▼ D(ciphertext, privateKey) │ ▼ Plain Data
Goal:
plainsecrecy
Sign / Verify
plainSIGNING Plain Message │ ▼ HASH(message) │ ▼ H │ privateKeyOperation(H) │ ▼ Signature
────────────────────────────────────
plainVERIFICATION Plain Message │ ▼ HASH(message) │ ▼ H1 Signature │ publicKeyOperation(signature) │ ▼ H2 Compare: H1 == H2 │ ┌─────────┴─────────┐ ▼ ▼ VALID INVALID
JWT — Bringing It All Together
Now that we understand:
- hashing
- symmetric cryptography
- asymmetric cryptography
- digital signatures
we can finally understand what a JWT is from first principles.
The Original Problem
Suppose a user logs in successfully.
Traditionally, the server would store session state:
plainsession_id -> user data
inside:
- memory
- Redis
- database
Then every future request would:
- send session ID
- server would look up auth state
But distributed systems made this painful:
- sticky sessions
- centralized session stores
- synchronization across servers
So the industry evolved toward:
plainclient-carried auth state
Meaning:
“Can the client itself carry proof that the user was authenticated earlier?”
Immediate Problem
If the client stores auth state:
plain{ "userId":123, "role":"admin" }
what stops the user from changing:
plain{ "userId":999, "role":"super-admin" }
The client fully controls the token.
So the real problem becomes:
plainHow can the server verify that this exact data was originally issued by us and not modified?
This is where JWT signing comes in.
JWT Structure
A JWT looks like this:
plainHEADER.PAYLOAD.SIGNATURE
Example:
plaineyJhbGciOiJIUzI1NiJ9 . eyJ1c2VySWQiOjEyM30 . abc123xyz
Important Clarification
The header and payload are:
plainBase64 encoded
NOT encrypted.
Meaning:
- anybody can decode them
- payload is readable
- JWT is usually not hiding data
JWT is primarily solving:
plaintrust
not secrecy.
HS256 JWT - Symmetric Signing
HS256 stands for:
plainHMAC + SHA256
This is:
- symmetric signing
- shared-secret based
- NOT public/private key cryptography
The same secret is used for:
- signing
- verification
Signing Workflow
Server creates:
Header
plain{ "alg":"HS256", "typ":"JWT" }
Payload
plain{ "userId":123, "role":"admin" }
Then:
plainbase64(header) + "." + base64(payload)
Result:
plainHEADER.PAYLOAD
Now the server computes:
plainsignature = HMAC(HEADER.PAYLOAD, SECRET_KEY)
Finally:
plainHEADER.PAYLOAD.SIGNATURE
is sent to the client.
HS256 Verification
When client sends JWT later:
plainHEADER.PAYLOAD.SIGNATURE
server does:
Step 1
Extract:
plainHEADER.PAYLOAD
and:
plainreceived_signature
Step 2
Recompute signature:
plainexpected_signature = HMAC(HEADER.PAYLOAD, SECRET_KEY)
Step 3
Compare:
plainexpected_signature === received_signature
If equal:
- token was not modified
- token was created by someone knowing SECRET_KEY
Visual Flow
SIGNING PROCESS
VERIFYING PROCESS
Key Insight
JWT HS256 is fundamentally:
plaintamper-proof client-carried state
not encrypted state.
The payload is readable.
The signature only guarantees:
plainthis payload was not modified and was originally signed using our secret
RS256 JWT — Asymmetric Signing
Earlier we saw HS256 JWTs using:
plainHMAC(message, SECRET)
where the same secret was used for:
- signing
- verification
That works well when:
plainsame system signs and verifies
But what happens when:
plainone authority signs many independent systems verify
This is where RS256 JWTs become important.
The Problem HS256 Cannot Solve Cleanly
Suppose Google issues login tokens.
Millions of applications need to verify:
plain"Did this token really come from Google?"
If Google used HS256:
plainall applications would need GOOGLE_SECRET
But then those applications could also:
plainmint fake Google tokens
because the same secret performs:
- signing
- verification
That breaks the trust model.
RS256 Solution
RS256 uses asymmetric cryptography.
Meaning:
plainprivate key signs public key verifies
Only the trusted authority owns:
plainprivate signing key
Everyone else receives:
plainpublic verification key
Public keys can:
- verify signatures
- NOT create valid signatures
This is the key architectural advantage.
RS256 JWT Signing Flow
Auth Server owns:
bashPRIVATE_KEY → used to produce signatures PUBLIC_KEY → shared with anyone who needs to verify
JWT payload:
bash{ "userId": 123, "role": "admin" }
Then internally:
bash1. H = HASH(header.payload) 2. block = encode(H) // pad + digest-info wrapper around H 3. SIGNATURE = privateKeyOp(block) 4. attach signature
Note: "signing" is not encrypting the hash directly. The hash gets wrapped in a fixed byte structure (padding + a digest-info wrapper identifying the hash algorithm), and the RSA private-key operation is applied to that whole structure.
Final JWT:
bashHEADER.PAYLOAD.SIGNATURE
RS256 Verification Flow (simplified)
Verifier receives:
bashHEADER.PAYLOAD.SIGNATURE
Then:
bash1. expectedBlock = encode(HASH(header.payload)) // built locally 2. recoveredBlock = publicKeyOp(signature) // from the token 3. compare expectedBlock === recoveredBlock // whole block, byte-for-byte
Note: the public-key operation does not hand back a clean hash — it hands back the whole padded block. You verify by independently building the block you expect and comparing the two blocks in full, padding included. Comparing only the bare hashes is the loose thinking that has led to real signature-forgery bugs.
If the blocks are equal:
- the token is authentic
- the payload was not modified
- the signer owned the matching private key
Mental Model
Private key = produce, public key = check.
Verification is reconstruct-and-compare, not decrypt-and-extract — you build the block you expect, recover the block from the signature, and they must match in full.
Real-World Backend Use Cases
RS256 is heavily used in:
- Google Login
- OAuth providers
- OpenID Connect (OIDC)
- Auth0
- Firebase Auth
- enterprise SSO
- centralized auth systems
- microservice architectures
The common pattern is always:
plainone trusted authority signs many systems verify
HS256 vs RS256 Mental Model
HS256
plainshared trust
Same secret:
- signs
- verifies
RS256
plaincentral authority trust
Private key:
- signs
Public key:
- verifies
Only signer can mint valid tokens.