thesubhstack

Cryptography in NodeJS

13 min readupdated

Introduction

Almost everything in backend security reduces to a few primitives:

  1. Hashing
  2. Encryption
  3. 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:

plain
input -> fixed-size output

Example:

plain
"hello" -> X123ABC

Properties:

  • deterministic
  • one-way
  • tiny input change → huge output change
  • not reversible

Mental Model

Server A sends:

plain
pay user $100

Server B hashes it:

plain
HASH(pay user $100)
-> A1B2C3

Later if somebody changes:

plain
pay user $900

Then:

plain
HASH(pay user $900)
-> Z9Y8X7

Hash changes completely.

That’s why hashing is useful for integrity.


Fake Toy Math

Real hashes are complex.

But imagine:

plain
HASH(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.

javascript
const 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:

plain
we should verify passwords
not decrypt passwords

Libraries like bcrypt and Argon2 are designed specifically for password hashing.

Simple Argon2 example:

bash
npm install argon2
javascript
const 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:

plain
plaintext -> ciphertext -> plaintext

Unlike hashing:

plain
encryption is reversible

Symmetric Encryption

Core Idea

Same secret key:

plain
encrypt(secret)
decrypt(secret)

Both servers must know the same secret.


Mental Model

Server A and Server B both know:

plain
SECRET_KEY = abc123

Server A encrypts:

plain
"database-password"

Server B decrypts using the same secret.


Fake Toy Math

Suppose:

plain
secret key = +5

Encrypt:

plain
10 + 5 = 15

Decrypt:

plain
15 - 5 = 10

Same secret used both ways.

Again:

  • fake
  • intuition only

Node.js Example

Using a beginner-friendly library.

bash
npm install crypto-js
javascript
const 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:

plain
public key
private key

Rule:

plain
encrypt(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:

plain
public operation  = +7
private operation = -7

Server A encrypts:

plain
10 + 7 = 17

Server B decrypts:

plain
17 - 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:

plain
public key and private key match

They are mathematically linked.

But:

plain
public key -> cannot practically derive private key

That asymmetry is the foundation.


Node.js Example

Using Node's built-in crypto module.

javascript
const 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:

  1. use asymmetric crypto to establish trust
  2. exchange a symmetric session key
  3. 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:

plain
signatures are NOT about secrecy

The message often remains readable.


Signature Flow

plain
message
  -> hash
  -> sign hash using private key
  -> signature

Receiver:

plain
message + signature
  -> verify using public key
  -> valid / invalid

Mental Model

Server B owns:

plain
public key
private key

Server B sends:

plain
"pay user $100"

But also attaches:

plain
signature

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.

plain
sign(hash(data))

This is:

  • faster
  • more efficient
  • standard practice

Fake Toy Math

Suppose:

plain
private signing operation = ×3
public verify operation   = ÷3

Hash:

plain
5

Sign:

plain
5 × 3 = 15

Signature:

plain
15

Verifier checks:

plain
15 ÷ 3 = 5

Matches original hash.

Result:

plain
VALID

Again:

  • fake
  • intuition only

The Direction Reversal

This is one of the most important ideas.

Encryption:

plain
encrypt(public)
decrypt(private)

Because:

plain
only owner should read

Signatures:

plain
sign(private)
verify(public)

Because:

plain
only owner should claim authorship

That’s why the directions flip.


Node.js Example

javascript
const 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.

GoalPrimitive
Create fingerprintHashing
Hide dataEncryption
Share secret securelyAsymmetric crypto
Encrypt efficientlySymmetric crypto
Prove authenticityDigital Signatures
Detect tamperingHashing + Signatures

To recap before we dive into JWT.

Encryption / Decryption

plain
Plain Data
    +
Public Key


E(data, publicKey)


Ciphertext


D(ciphertext, privateKey)


Plain Data

Goal:

plain
secrecy

Sign / Verify

plain
SIGNING

        Plain Message


         HASH(message)


               H

 privateKeyOperation(H)


           Signature

────────────────────────────────────

plain
VERIFICATION

        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:

plain
session_id -> user data

inside:

  • memory
  • Redis
  • database

Then every future request would:

  1. send session ID
  2. 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:

plain
client-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:

plain
How 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:

plain
HEADER.PAYLOAD.SIGNATURE

Example:

plain
eyJhbGciOiJIUzI1NiJ9
.
eyJ1c2VySWQiOjEyM30
.
abc123xyz

Important Clarification

The header and payload are:

plain
Base64 encoded

NOT encrypted.

Meaning:

  • anybody can decode them
  • payload is readable
  • JWT is usually not hiding data

JWT is primarily solving:

plain
trust

not secrecy.


HS256 JWT - Symmetric Signing

HS256 stands for:

plain
HMAC + 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:

plain
{
  "alg":"HS256",
  "typ":"JWT"
}

Payload

plain
{
  "userId":123,
  "role":"admin"
}

Then:

plain
base64(header) + "." + base64(payload)

Result:

plain
HEADER.PAYLOAD

Now the server computes:

plain
signature =
HMAC(HEADER.PAYLOAD, SECRET_KEY)

Finally:

plain
HEADER.PAYLOAD.SIGNATURE

is sent to the client.


HS256 Verification

When client sends JWT later:

plain
HEADER.PAYLOAD.SIGNATURE

server does:

Step 1

Extract:

plain
HEADER.PAYLOAD

and:

plain
received_signature

Step 2

Recompute signature:

plain
expected_signature =
HMAC(HEADER.PAYLOAD, SECRET_KEY)

Step 3

Compare:

plain
expected_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:

plain
tamper-proof client-carried state

not encrypted state.

The payload is readable.

The signature only guarantees:

plain
this payload was not modified
and was originally signed using our secret

RS256 JWT — Asymmetric Signing

Earlier we saw HS256 JWTs using:

plain
HMAC(message, SECRET)

where the same secret was used for:

  • signing
  • verification

That works well when:

plain
same system signs and verifies

But what happens when:

plain
one 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:

plain
all applications would need GOOGLE_SECRET

But then those applications could also:

plain
mint fake Google tokens

because the same secret performs:

  • signing
  • verification

That breaks the trust model.


RS256 Solution

RS256 uses asymmetric cryptography.

Meaning:

plain
private key signs
public key verifies

Only the trusted authority owns:

plain
private signing key

Everyone else receives:

plain
public verification key

Public keys can:

  • verify signatures
  • NOT create valid signatures

This is the key architectural advantage.


RS256 JWT Signing Flow

Auth Server owns:

bash
PRIVATE_KEY used to produce signatures
PUBLIC_KEY shared with anyone who needs to verify

JWT payload:

bash
{
  "userId": 123,
  "role": "admin"
}

Then internally:

bash
1. 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:

bash
HEADER.PAYLOAD.SIGNATURE


RS256 Verification Flow (simplified)

Verifier receives:

bash
HEADER.PAYLOAD.SIGNATURE

Then:

bash
1. 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:

plain
one trusted authority signs
many systems verify

HS256 vs RS256 Mental Model

HS256

plain
shared trust

Same secret:

  • signs
  • verifies

RS256

plain
central authority trust

Private key:

  • signs

Public key:

  • verifies

Only signer can mint valid tokens.