DEV Community

Curious Kid
Curious Kid

Posted on

Understanding RS256: A Deep Dive into Asymmetric Encryption

How JWT tokens stay secure without sharing secrets


If you've ever worked with JWTs (JSON Web Tokens), you've probably encountered RS256. But have you ever wondered what actually happens behind the scenes? How can a server verify a token's authenticity without knowing the secret used to create it?

Today, we're going on a journey from confusion to clarity. We'll explore asymmetric encryption, understand how RS256 works, and even generate our own RSA keys from scratch.

The Problem with Symmetric Encryption

Let's start with something familiar: HS256 (HMAC with SHA-256).

When you use HS256, here's what happens:

// Creating a token
const token = base64(header) + '.' + 
              base64(payload) + '.' + 
              HMAC_SHA256(header + payload, SECRET_KEY)
Enter fullscreen mode Exit fullscreen mode

To verify this token, the server does:

// Verifying the token
const recreatedSignature = HMAC_SHA256(header + payload, SECRET_KEY)
if (recreatedSignature === receivedSignature) {
  // Token is valid!
}
Enter fullscreen mode Exit fullscreen mode

The problem? Anyone who can verify tokens can also create them. Why? Because the same secret is used for both operations.

This means:

  • Your API servers need the secret
  • Your microservices need the secret
  • Every service that verifies tokens needs the secret

One compromised service = your entire authentication system is compromised.

Enter Asymmetric Encryption: RS256

RS256 solves this elegantly with a brilliant concept: key pairs.

Instead of one secret, you have:

  • πŸ” Private Key - Creates signatures (kept SECRET)
  • πŸ”“ Public Key - Verifies signatures (shared OPENLY)

Here's the magic: What's encrypted with one key can ONLY be decrypted with its pair.

The Real-World Architecture

Let's see how this works in a typical web application:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Frontend      β”‚  1. User logs in with credentials
β”‚   (Browser)     │────────────────────┐
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β”‚
                                       β–Ό
                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                              β”‚  Auth Server    β”‚
                              β”‚                 β”‚
                              β”‚  πŸ” Private Key β”‚ (signing)
                              β”‚  πŸ”“ Public Key  β”‚ (exposed)
                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                       β”‚
                         2. Creates & signs JWT
                         with PRIVATE key
                                       β”‚
                                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Frontend      β”‚         β”‚  JWT Token      β”‚
β”‚                 │◄────────│  eyJhbGci...    β”‚
β”‚  Stores token   β”‚    3.   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚ 4. API request with token
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   API Server    β”‚  5. Fetches PUBLIC key
β”‚                 β”‚  6. Verifies signature
β”‚  πŸ”“ Public Key  β”‚  βœ… Token valid!
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • Private key stays ONLY on the auth server
  • Public key can be shared with everyone
  • API servers can verify but cannot create tokens
  • Frontend just stores and sends tokens (doesn't verify)

How RS256 Actually Works

Let's break down the process step by step.

Token Creation (Auth Server)

// 1. Create header and payload
const header = {
  "alg": "RS256",
  "typ": "JWT",
  "kid": "auth-key-2024"
}

const payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "email": "john@example.com",
  "exp": 1728129600
}

// 2. Encode to base64url
const headerB64 = base64url(JSON.stringify(header))
const payloadB64 = base64url(JSON.stringify(payload))

// 3. Create hash
const dataToSign = headerB64 + '.' + payloadB64
const hash = SHA256(dataToSign)

// 4. ENCRYPT the hash with PRIVATE key
const signature = RSA_ENCRYPT(hash, PRIVATE_KEY)

// 5. Complete JWT
const jwt = headerB64 + '.' + payloadB64 + '.' + signature
Enter fullscreen mode Exit fullscreen mode

Token Verification (API Server)

// 1. Split the JWT
const [headerB64, payloadB64, signature] = jwt.split('.')

// 2. Recreate the hash
const dataToVerify = headerB64 + '.' + payloadB64
const hash_A = SHA256(dataToVerify)

// 3. DECRYPT the signature with PUBLIC key
const hash_B = RSA_DECRYPT(signature, PUBLIC_KEY)

// 4. Compare
if (hash_A === hash_B) {
  console.log('βœ… Token is valid!')
} else {
  console.log('❌ Token is forged!')
}
Enter fullscreen mode Exit fullscreen mode

Why Can't Someone Forge It?

Let's say an attacker tries to change user_id: 5 to user_id: 1:

  1. Attacker modifies the payload
  2. Attacker hashes the new header + payload β†’ gets hash_X
  3. But they can't create a valid signature!
    • They don't have the private key
    • Even if they encrypt hash_X with the public key (wrong direction!), it won't work
  4. When the server decrypts the signature with the public key, it won't match hash_X
  5. Verification fails!

The mathematical relationship ensures that signatures created with the private key can ONLY be verified with the public key, but the public key cannot create valid signatures.

The Math Behind RSA Keys

Now for the fascinating part: how are these magical key pairs actually created?

Step-by-Step Key Generation

Step 1: Choose Two Large Prime Numbers

p = 61
q = 53
Enter fullscreen mode Exit fullscreen mode

In production, these would be 1024-bit numbers (309 digits each!), but we'll use small numbers for learning.

Step 2: Calculate n (Modulus)

n = p Γ— q
n = 61 Γ— 53
n = 3233
Enter fullscreen mode Exit fullscreen mode

This n becomes part of BOTH keys.

Step 3: Calculate Ο†(n) - Euler's Totient Function

Ο†(n) = (p - 1) Γ— (q - 1)
Ο†(n) = (61 - 1) Γ— (53 - 1)
Ο†(n) = 60 Γ— 52
Ο†(n) = 3120
Enter fullscreen mode Exit fullscreen mode

This is the "secret sauce" used only during key generation.

Step 4: Choose Public Exponent e

e = 17  (or commonly 65537 in production)

Requirements:
- 1 < e < Ο†(n)
- gcd(e, Ο†(n)) = 1  (they must be coprime)
Enter fullscreen mode Exit fullscreen mode

Step 5: Calculate Private Exponent d

Find d such that: (d Γ— e) mod Ο†(n) = 1

(d Γ— 17) mod 3120 = 1

Using the Extended Euclidean Algorithm:
d = 2753

Verify: (2753 Γ— 17) mod 3120 = 46801 mod 3120 = 1 βœ“
Enter fullscreen mode Exit fullscreen mode

Step 6: Create the Key Pair

Public Key  = (e, n) = (17, 3233)
Private Key = (d, n) = (2753, 3233)

πŸ”₯ Destroy p, q, and Ο†(n) - they must NEVER be revealed!
Enter fullscreen mode Exit fullscreen mode

Testing Our Keys

Let's sign a message!

Message: "HI" = 7273 (as a number)

Signing with Private Key:

Signature = Message^d mod n
Signature = 7273^2753 mod 3233
Signature = 1570
Enter fullscreen mode Exit fullscreen mode

Verifying with Public Key:

Decrypted = Signature^e mod n
Decrypted = 1570^17 mod 3233
Decrypted = 7273 βœ…

Success! We got back our original message!
Enter fullscreen mode Exit fullscreen mode

Why This is Secure

The security relies on a beautiful mathematical fact: factoring large numbers is extremely hard.

To break RSA, an attacker would need to:

  1. Factor n back into p and q
  2. Calculate Ο†(n) = (p-1) Γ— (q-1)
  3. Calculate d from e and Ο†(n)

For our small example:

  • n = 3233
  • Factoring: 3233 = 61 Γ— 53 (easy!)

For production (2048-bit keys):

  • n = 617-digit number
  • Factoring would take thousands of years with current computers!

This is why RSA is secure.

Generating Real RSA Keys

Let's write Python code to generate actual RSA keys:

import random

def is_prime(n, k=5):
    """Miller-Rabin primality test"""
    if n < 2: return False
    for p in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]:
        if n == p: return True
        if n % p == 0: return False

    r, d = 0, n - 1
    while d % 2 == 0:
        r += 1
        d //= 2

    for _ in range(k):
        a = random.randrange(2, n - 1)
        x = pow(a, d, n)
        if x == 1 or x == n - 1:
            continue
        for _ in range(r - 1):
            x = pow(x, 2, n)
            if x == n - 1:
                break
        else:
            return False
    return True

def generate_prime(bits):
    """Generate a prime number with specified bit length"""
    while True:
        num = random.getrandbits(bits)
        num |= (1 << bits - 1) | 1
        if is_prime(num):
            return num

def gcd(a, b):
    """Greatest common divisor"""
    while b:
        a, b = b, a % b
    return a

def extended_gcd(a, b):
    """Extended Euclidean Algorithm"""
    if a == 0:
        return b, 0, 1
    gcd_val, x1, y1 = extended_gcd(b % a, a)
    x = y1 - (b // a) * x1
    y = x1
    return gcd_val, x, y

def mod_inverse(e, phi):
    """Calculate modular multiplicative inverse"""
    gcd_val, x, _ = extended_gcd(e, phi)
    if gcd_val != 1:
        raise Exception('Modular inverse does not exist')
    return (x % phi + phi) % phi

def generate_rsa_keys(bits=512):
    """Generate RSA key pair"""

    print(f"πŸ”‘ Generating RSA Keys ({bits} bits)")
    print("=" * 60)

    # Step 1: Generate primes
    print(f"\nπŸ“Š Step 1: Generating prime p...")
    p = generate_prime(bits // 2)
    print(f"βœ“ p = {p}")

    print(f"\nπŸ“Š Step 2: Generating prime q...")
    q = generate_prime(bits // 2)
    print(f"βœ“ q = {q}")

    # Step 2: Calculate n
    print(f"\nπŸ“Š Step 3: Calculating n = p Γ— q")
    n = p * q
    print(f"βœ“ n = {n}")

    # Step 3: Calculate Ο†(n)
    print(f"\nπŸ“Š Step 4: Calculating Ο†(n) = (p-1) Γ— (q-1)")
    phi = (p - 1) * (q - 1)
    print(f"βœ“ Ο†(n) = {phi}")

    # Step 4: Choose e
    print(f"\nπŸ“Š Step 5: Choosing public exponent e")
    e = 65537
    print(f"βœ“ e = {e}")

    # Step 5: Calculate d
    print(f"\nπŸ“Š Step 6: Calculating private exponent d")
    d = mod_inverse(e, phi)
    print(f"βœ“ d = {d}")

    # Results
    print("\n" + "=" * 60)
    print("πŸŽ‰ KEY GENERATION COMPLETE")
    print("=" * 60)

    public_key = (e, n)
    private_key = (d, n)

    print(f"\nπŸ”“ Public Key (e, n):")
    print(f"   e = {e}")
    print(f"   n = {n}")

    print(f"\nπŸ” Private Key (d, n):")
    print(f"   d = {d}")
    print(f"   n = {n}")

    return public_key, private_key, (p, q)

# Generate keys
public_key, private_key, primes = generate_rsa_keys(512)

# Test signing and verification
print("\n" + "=" * 60)
print("πŸ§ͺ TESTING KEYS")
print("=" * 60)

message = 42
print(f"\nπŸ“ Original message: {message}")

e, n = public_key
d, _ = private_key

# Sign with private key
signature = pow(message, d, n)
print(f"✍️  Signature (encrypted with private key): {signature}")

# Verify with public key
decrypted = pow(signature, e, n)
print(f"πŸ” Decrypted (with public key): {decrypted}")

if decrypted == message:
    print("\nβœ… SUCCESS! Signature verified!")
else:
    print("\n❌ FAILED! Something went wrong.")
Enter fullscreen mode Exit fullscreen mode

Sample Output

πŸ”‘ Generating RSA Keys (512 bits)
============================================================

πŸ“Š Step 1: Generating prime p...
βœ“ p = 9567899421673...

πŸ“Š Step 2: Generating prime q...
βœ“ q = 10616879021273...

πŸ“Š Step 3: Calculating n = p Γ— q
βœ“ n = 101543264894757...

πŸ“Š Step 4: Calculating Ο†(n) = (p-1) Γ— (q-1)
βœ“ Ο†(n) = 101543264894757...

πŸ“Š Step 5: Choosing public exponent e
βœ“ e = 65537

πŸ“Š Step 6: Calculating private exponent d
βœ“ d = 476547399413147...

============================================================
πŸŽ‰ KEY GENERATION COMPLETE
============================================================

πŸ”“ Public Key (e, n):
   e = 65537
   n = 101543264894757...

πŸ” Private Key (d, n):
   d = 476547399413147...
   n = 101543264894757...

============================================================
πŸ§ͺ TESTING KEYS
============================================================

πŸ“ Original message: 42
✍️  Signature (encrypted with private key): 823456712345...
πŸ” Decrypted (with public key): 42

βœ… SUCCESS! Signature verified!
Enter fullscreen mode Exit fullscreen mode

From Numbers to PEM Files

You might wonder: "How do these huge numbers become those PEM files with -----BEGIN RSA PRIVATE KEY-----?"

Great question! Here's the conversion process:

Step 1: Numbers β†’ Bytes

# Convert numbers to binary bytes
n_bytes = n.to_bytes((n.bit_length() + 7) // 8, byteorder='big')
e_bytes = e.to_bytes((e.bit_length() + 7) // 8, byteorder='big')
d_bytes = d.to_bytes((d.bit_length() + 7) // 8, byteorder='big')
Enter fullscreen mode Exit fullscreen mode

Step 2: ASN.1 DER Encoding

RSA keys use ASN.1 (Abstract Syntax Notation One) to structure the data:

from pyasn1.codec.der import encoder
from pyasn1.type import univ

# Create ASN.1 structure
private_key_asn1 = univ.Sequence()
private_key_asn1.setComponentByPosition(0, univ.Integer(0))  # version
private_key_asn1.setComponentByPosition(1, univ.Integer(n))  # modulus
private_key_asn1.setComponentByPosition(2, univ.Integer(e))  # public exp
private_key_asn1.setComponentByPosition(3, univ.Integer(d))  # private exp
# ... more components

# Encode to binary DER format
der_bytes = encoder.encode(private_key_asn1)
Enter fullscreen mode Exit fullscreen mode

Step 3: Base64 Encoding

Convert binary to ASCII-safe text:

import base64

base64_encoded = base64.b64encode(der_bytes).decode('ascii')
# Result: "MIIEpAIBAAKCAQEAu1SU1LfVLPHCozMx..."
Enter fullscreen mode Exit fullscreen mode

Step 4: PEM Formatting

Add headers, footers, and line breaks:

def create_pem(base64_data):
    # Split into 64-character lines
    lines = [base64_data[i:i+64] for i in range(0, len(base64_data), 64)]

    pem = "-----BEGIN RSA PRIVATE KEY-----\n"
    pem += "\n".join(lines)
    pem += "\n-----END RSA PRIVATE KEY-----"

    return pem
Enter fullscreen mode Exit fullscreen mode

Final Result:

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gun
VTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxK
LxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo
9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvre
dZW2xoR3ZldZ+JYqSyzwDJKov8nBNhFBZYDrVIeCfHXfqQH+5lA5WXxOFkBHkYLj
VZKLm07W5kkEJm5ULALMKOJlPLQHqBkJ1gP8bQIDAQABAoIBAFbL3H7hX5z6AHQL
...
-----END RSA PRIVATE KEY-----
Enter fullscreen mode Exit fullscreen mode

Real-World Implementation

Here's how to use RS256 in Node.js:

const crypto = require('crypto');
const fs = require('fs');

// Generate key pair (do this once, during setup)
function generateKeyPair() {
  const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem'
    }
  });

  fs.writeFileSync('private_key.pem', privateKey);
  fs.writeFileSync('public_key.pem', publicKey);

  console.log('βœ… Keys generated and saved!');
}

// Sign a JWT (Auth Server)
function createJWT(payload) {
  const privateKey = fs.readFileSync('private_key.pem', 'utf8');

  const header = Buffer.from(JSON.stringify({
    alg: 'RS256',
    typ: 'JWT'
  })).toString('base64url');

  const payloadB64 = Buffer.from(JSON.stringify(payload))
    .toString('base64url');

  const dataToSign = `${header}.${payloadB64}`;

  const signature = crypto
    .createSign('RSA-SHA256')
    .update(dataToSign)
    .sign(privateKey, 'base64url');

  return `${header}.${payloadB64}.${signature}`;
}

// Verify a JWT (API Server)
function verifyJWT(token) {
  const publicKey = fs.readFileSync('public_key.pem', 'utf8');

  const [headerB64, payloadB64, signature] = token.split('.');
  const dataToVerify = `${headerB64}.${payloadB64}`;

  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(dataToVerify);

  const isValid = verifier.verify(publicKey, signature, 'base64url');

  if (isValid) {
    const payload = JSON.parse(
      Buffer.from(payloadB64, 'base64url').toString()
    );
    return { valid: true, payload };
  }

  return { valid: false };
}

// Usage
generateKeyPair();

const jwt = createJWT({
  sub: '1234567890',
  name: 'John Doe',
  exp: Math.floor(Date.now() / 1000) + 3600
});

console.log('JWT:', jwt);

const result = verifyJWT(jwt);
console.log('Verification:', result);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. RS256 uses asymmetric encryption - different keys for signing and verifying
  2. Private keys sign, public keys verify - this separation is the key to security
  3. RSA key generation involves prime factorization - the difficulty of reversing this process provides security
  4. Mathematical properties ensure security - what's encrypted with one key can only be decrypted with its pair
  5. PEM format is just encoded numbers - fancy strings, but math underneath

When to Use RS256 vs HS256

Use RS256 when:

  • Multiple services need to verify tokens
  • You want to distribute verification (public key can be shared)
  • Security is critical (compromised API servers can't forge tokens)
  • Microservices architecture

Use HS256 when:

  • Single service handles both creation and verification
  • Simpler setup needed
  • Performance is critical (symmetric operations are faster)

Conclusion

Asymmetric encryption might seem like magic, but it's beautiful mathematics. From choosing prime numbers to creating those PEM files, every step serves a purpose in keeping our applications secure.

The next time you see a JWT with RS256, you'll know exactly what's happening: a private key created a signature that only its public key pair can verify, all thanks to the elegant mathematics of RSA encryption.

Now go forth and implement secure authentication with confidence! πŸš€


Have questions or want to dive deeper? The entire code from this article is available for experimentation. Try generating your own keys and see the math in action!

Top comments (0)