You Don't Know JSON: Part 7 - Security: Authentication, Signatures, and Attacks

Master JSON security: JWT authentication, JWS signing, JWE encryption, and common attacks. Learn canonicalization, algorithm confusion, injection vulnerabilities, and production security best practices.

In Part 1 , we explored JSON’s origins. In Part 2 , we added validation. In Part 3 and Part 4 , we optimized with binary formats. In Part 5 , we built RPC protocols. In Part 6 , we enabled streaming.

Now we complete the series with JSON’s most critical missing piece: security.

The Security Gap: JSON provides no authentication, no encryption, no signing, no integrity checking. It’s pure data with zero security primitives. In a world where JSON carries user credentials, financial data, and access tokens across the internet, this incompleteness creates serious vulnerabilities.

What XML Had: XML Signature and XML Encryption (2000-2002)

XML’s approach: Comprehensive security built into the core specification. XML Signature for digital signatures, XML Encryption for confidentiality, WS-Security for SOAP authentication - all integrated with complex canonicalization and namespace handling.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- XML Signature: Built-in but complex -->
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
  <SignedInfo>
    <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
    <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
    <Reference URI="">
      <Transforms>...</Transforms>
      <DigestValue>...</DigestValue>
    </Reference>
  </SignedInfo>
  <SignatureValue>...</SignatureValue>
</Signature>

Benefit: Complete cryptographic infrastructure, standardized across tools
Cost: Extreme complexity, canonicalization nightmares, implementation errors common

JSON’s approach: Separate security standards (JWT, JWS, JWE) - modular composition

Architecture shift: Built-in security → Composable security layers, Monolithic → Mix-and-match, Complex canonicalization → Simple Base64 encoding

This article covers:

  • JWT (JSON Web Tokens) for stateless authentication
  • JWS (JSON Web Signature) for integrity and authenticity
  • JWE (JSON Web Encryption) for confidentiality
  • Canonicalization for consistent signatures
  • Common attacks and vulnerabilities
  • Production security best practices

Running Example: Securing the User API

In Part 1 , we created basic JSON users. In Part 2 , we added validation. In Part 3 , we stored them in JSONB. In Part 5 , we added protocol structure. In Part 6 , we enabled streaming exports.

Now we complete the journey with the security layer - protecting our User API with JWT authentication.

Login flow (JWT authentication):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 1. User logs in
POST /auth/login
{
  "username": "alice", 
  "password": "secret123"
}

// 2. Server returns JWT
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 900
}

// 3. Client includes JWT in API calls
GET /api/users/user-5f9d88c
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

JWT payload (our user data):

1
2
3
4
5
6
7
8
{
  "sub": "user-5f9d88c",
  "username": "alice", 
  "email": "alice@example.com",
  "roles": ["user", "verified"],
  "iat": 1735686000,
  "exp": 1735686900
}

Critical security considerations:

  • Algorithm confusion attacks (RS256 → HS256)
  • Token substitution (using valid token for wrong user)
  • Weak secrets (brute-forceable HMAC keys)
  • Missing expiration checks
  • JWT injection in user profile updates

This completes the security layer for our User API - from basic JSON to production-ready authenticated system.


The Security Problem

JSON Carries Sensitive Data

Modern applications send JSON everywhere:

1
2
3
4
5
6
{
  "user": "alice",
  "email": "alice@example.com",
  "creditCard": "4532-1234-5678-9010",
  "ssn": "123-45-6789"
}

Questions JSON can’t answer:

  • Is this data from a trusted source?
  • Has it been tampered with in transit?
  • Should it be encrypted?
  • How do we verify the sender’s identity?

Standard JSON provides zero answers. It’s the application’s responsibility to handle security.

What XML Had (For Better or Worse)

XML included security specifications:

  • XML Signature - Digital signatures for XML documents
  • XML Encryption - Encrypt XML elements
  • WS-Security - SOAP security extensions

The problem: Monolithic, complex, difficult to implement correctly. The specifications were hundreds of pages. Few developers understood them fully.

The JSON Approach: Separate Security Standards

Instead of building security into JSON, the ecosystem created modular standards:

JWT (JSON Web Token): Represent claims securely JWS (JSON Web Signature): Sign JSON data
JWE (JSON Web Encryption): Encrypt JSON data

Each is independent, composable, and focuses on one problem.


JWT: JSON Web Tokens

What JWT Is

JWT (RFC 7519) is a compact, URL-safe format for representing claims between two parties.

Structure:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts (separated by .):

  1. Header - Algorithm and token type
  2. Payload - Claims (data)
  3. Signature - Cryptographic signature

JWT Structure

Header (Base64URL encoded):

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload (Base64URL encoded):

1
2
3
4
5
6
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}

Signature:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

Standard Claims

Registered claims (RFC 7519):

ClaimNameMeaning
issIssuerWho created the token
subSubjectWho the token is about
audAudienceWho should accept the token
expExpirationWhen token expires (Unix timestamp)
nbfNot BeforeToken not valid before this time
iatIssued AtWhen token was created
jtiJWT IDUnique identifier

Example with standard claims:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "iss": "https://auth.example.com",
  "sub": "user-12345",
  "aud": "https://api.example.com",
  "exp": 1735689600,
  "iat": 1735686000,
  "name": "Alice Johnson",
  "email": "alice@example.com",
  "roles": ["user", "admin"]
}

Creating JWTs

Node.js (jsonwebtoken):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const jwt = require('jsonwebtoken');

// Create token
const payload = {
  sub: 'user-12345',
  name: 'Alice Johnson',
  email: 'alice@example.com',
  roles: ['user', 'admin']
};

const secret = process.env.JWT_SECRET;

const token = jwt.sign(payload, secret, {
  expiresIn: '1h',
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com'
});

console.log(token);

Go (golang-jwt):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import (
	"time"
	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	Name  string   `json:"name"`
	Email string   `json:"email"`
	Roles []string `json:"roles"`
	jwt.RegisteredClaims
}

func createToken() (string, error) {
	claims := Claims{
		Name:  "Alice Johnson",
		Email: "alice@example.com",
		Roles: []string{"user", "admin"},
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   "user-12345",
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "https://auth.example.com",
			Audience:  []string{"https://api.example.com"},
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(secret))
}

Python (PyJWT):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import jwt
import datetime

payload = {
    'sub': 'user-12345',
    'name': 'Alice Johnson',
    'email': 'alice@example.com',
    'roles': ['user', 'admin'],
    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),
    'iat': datetime.datetime.utcnow(),
    'iss': 'https://auth.example.com',
    'aud': 'https://api.example.com'
}

secret = os.environ['JWT_SECRET']

token = jwt.encode(payload, secret, algorithm='HS256')
print(token)

Verifying JWTs

Node.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
try {
  const decoded = jwt.verify(token, secret, {
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com'
  });
  
  console.log('User:', decoded.name);
  console.log('Roles:', decoded.roles);
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    console.error('Token expired');
  } else if (err.name === 'JsonWebTokenError') {
    console.error('Invalid token');
  }
}

Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func verifyToken(tokenString string) (*Claims, error) {
	claims := &Claims{}
	
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		// Verify signing method
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(secret), nil
	})
	
	if err != nil {
		return nil, err
	}
	
	if !token.Valid {
		return nil, fmt.Errorf("invalid token")
	}
	
	// Verify claims
	if err := claims.Valid(); err != nil {
		return nil, err
	}
	
	return claims, nil
}

Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
try:
    decoded = jwt.decode(
        token,
        secret,
        algorithms=['HS256'],
        issuer='https://auth.example.com',
        audience='https://api.example.com'
    )
    
    print(f"User: {decoded['name']}")
    print(f"Roles: {decoded['roles']}")
    
except jwt.ExpiredSignatureError:
    print("Token expired")
except jwt.InvalidTokenError:
    print("Invalid token")

JWT Use Cases

1. API Authentication:

1
2
3
GET /api/users/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

2. Single Sign-On (SSO):

  • User logs in once
  • Receives JWT from auth server
  • Uses JWT across multiple services

3. Information Exchange:

  • Sign data to prove it came from trusted source
  • Include expiration to limit validity window

4. Stateless Sessions:

  • No server-side session storage
  • All session data in JWT
  • Scales horizontally
sequenceDiagram participant User participant AuthServer participant API User->>AuthServer: Login (username, password) AuthServer->>AuthServer: Verify credentials AuthServer->>User: JWT token User->>API: Request + JWT API->>API: Verify JWT signature API->>API: Check expiration API->>User: Protected resource Note over API: No database lookup
All info in JWT

JWS: JSON Web Signature

What JWS Is

JWS (RFC 7515) provides integrity and authenticity for JSON data through digital signatures.

JWT is actually a JWS - the signature part of JWT uses JWS.

Signing Algorithms

Symmetric (HMAC):

1
2
3
{
  "alg": "HS256"  // HMAC + SHA-256
}
  • Same secret for signing and verification
  • Fast
  • Requires shared secret

Asymmetric (RSA, ECDSA):

1
2
3
{
  "alg": "RS256"  // RSA + SHA-256
}
1
2
3
{
  "alg": "ES256"  // ECDSA + P-256 + SHA-256
}
  • Private key signs, public key verifies
  • No shared secret needed
  • Slower than HMAC

Algorithm comparison:

AlgorithmTypeKey SizeSpeedUse Case
HS256HMAC+SHA256256 bitsFastShared secret scenarios
HS384HMAC+SHA384384 bitsFastHigher security HMAC
HS512HMAC+SHA512512 bitsFastMaximum security HMAC
RS256RSA+SHA2562048+ bitsSlowPublic verification
RS384RSA+SHA3842048+ bitsSlowHigher security RSA
RS512RSA+SHA5122048+ bitsSlowMaximum security RSA
ES256ECDSA+P-256256 bitsMediumModern, efficient
ES384ECDSA+P-384384 bitsMediumHigher security ECDSA
ES512ECDSA+P-521521 bitsMediumMaximum security ECDSA

RSA Signing Example

Generate keys:

1
2
3
4
5
# Private key
openssl genrsa -out private.pem 2048

# Public key
openssl rsa -in private.pem -pubout -out public.pem

Node.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const fs = require('fs');
const jwt = require('jsonwebtoken');

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// Sign with private key
const token = jwt.sign(payload, privateKey, {
  algorithm: 'RS256',
  expiresIn: '1h'
});

// Verify with public key
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']
});

Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import (
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"os"
)

func loadRSAKeys() (*rsa.PrivateKey, *rsa.PublicKey, error) {
	// Load private key
	privBytes, _ := os.ReadFile("private.pem")
	privBlock, _ := pem.Decode(privBytes)
	privKey, err := x509.ParsePKCS1PrivateKey(privBlock.Bytes)
	if err != nil {
		return nil, nil, err
	}
	
	// Load public key
	pubBytes, _ := os.ReadFile("public.pem")
	pubBlock, _ := pem.Decode(pubBytes)
	pubInterface, err := x509.ParsePKIXPublicKey(pubBlock.Bytes)
	if err != nil {
		return nil, nil, err
	}
	pubKey := pubInterface.(*rsa.PublicKey)
	
	return privKey, pubKey, nil
}

func createRSAToken() (string, error) {
	privKey, _, err := loadRSAKeys()
	if err != nil {
		return "", err
	}
	
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	return token.SignedString(privKey)
}

func verifyRSAToken(tokenString string) (*Claims, error) {
	_, pubKey, err := loadRSAKeys()
	if err != nil {
		return nil, err
	}
	
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		return pubKey, nil
	})
	
	if err != nil || !token.Valid {
		return nil, err
	}
	
	return claims, nil
}

ECDSA Signing Example

Generate keys:

1
2
3
4
5
# Private key
openssl ecparam -genkey -name prime256v1 -noout -out ec-private.pem

# Public key
openssl ec -in ec-private.pem -pubout -out ec-public.pem

Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

# Load keys
with open('ec-private.pem', 'rb') as f:
    private_key = serialization.load_pem_private_key(
        f.read(),
        password=None,
        backend=default_backend()
    )

with open('ec-public.pem', 'rb') as f:
    public_key = serialization.load_pem_public_key(
        f.read(),
        backend=default_backend()
    )

# Sign
token = jwt.encode(payload, private_key, algorithm='ES256')

# Verify
decoded = jwt.decode(token, public_key, algorithms=['ES256'])

JWE: JSON Web Encryption

What JWE Is

JWE (RFC 7516) provides confidentiality for JSON data through encryption.

Structure:

BASE64URL(Header).
BASE64URL(Encrypted Key).
BASE64URL(Initialization Vector).
BASE64URL(Ciphertext).
BASE64URL(Authentication Tag)

Five parts (vs three for JWT/JWS):

  1. Header - Algorithm and encryption method
  2. Encrypted Key - Encrypted content encryption key
  3. IV - Initialization vector for encryption
  4. Ciphertext - Encrypted payload
  5. Authentication Tag - Integrity check

JWE Algorithms

Key encryption algorithms:

  • RSA-OAEP - RSA with OAEP padding
  • RSA-OAEP-256 - RSA with SHA-256
  • A128KW - AES Key Wrap with 128-bit key
  • A256KW - AES Key Wrap with 256-bit key
  • dir - Direct use of shared symmetric key
  • ECDH-ES - Elliptic Curve Diffie-Hellman

Content encryption algorithms:

  • A128GCM - AES-GCM with 128-bit key
  • A256GCM - AES-GCM with 256-bit key
  • A128CBC-HS256 - AES-CBC + HMAC-SHA256

Creating JWE

Node.js (jose):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const jose = require('jose');

async function createJWE() {
  // Generate key
  const secret = new TextEncoder().encode(
    'your-256-bit-secret-key-here-32-bytes!!'
  );
  
  const payload = {
    sub: 'user-12345',
    name: 'Alice Johnson',
    email: 'alice@example.com',
    ssn: '123-45-6789'  // Sensitive data
  };
  
  const jwe = await new jose.EncryptJWT(payload)
    .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
    .setIssuedAt()
    .setExpirationTime('1h')
    .encrypt(secret);
  
  return jwe;
}

async function decryptJWE(jwe) {
  const secret = new TextEncoder().encode(
    'your-256-bit-secret-key-here-32-bytes!!'
  );
  
  const { payload } = await jose.jwtDecrypt(jwe, secret);
  return payload;
}

Python (python-jose):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from jose import jwe
from jose import jwt

# Encrypt
secret = 'your-256-bit-secret-key-here-32-bytes!!'

payload = {
    'sub': 'user-12345',
    'name': 'Alice Johnson',
    'email': 'alice@example.com',
    'ssn': '123-45-6789'
}

encrypted = jwe.encrypt(
    json.dumps(payload),
    secret,
    algorithm='dir',
    encryption='A256GCM'
)

# Decrypt
decrypted_bytes = jwe.decrypt(encrypted, secret)
decrypted_payload = json.loads(decrypted_bytes)

When to Use JWE

Use JWE when:

  • Payload contains sensitive data (PII, credentials)
  • Data crosses untrusted networks
  • Compliance requires encryption at rest/transit
  • Need end-to-end encryption

Don’t use JWE when:

  • JWT signature is sufficient (data not sensitive)
  • TLS already provides transport encryption
  • Performance critical (JWE is slower than JWS)
JWE vs TLS: JWE provides end-to-end encryption (only sender and recipient can decrypt). TLS provides transport encryption (protected in transit, but visible to intermediaries with TLS access). For most APIs, TLS is sufficient. Use JWE when you need protection beyond transport layer.

Canonicalization: Consistent Signatures

The Problem

JSON doesn’t define canonical form:

1
{"name":"Alice","age":30}
1
2
3
4
{
  "age": 30,
  "name": "Alice"
}
1
{"name": "Alice", "age": 30}

All are equivalent JSON, but produce different signatures due to whitespace and key ordering.

Why It Matters

Problem scenario:

  1. Server signs JSON: {"name":"Alice","age":30}
  2. Client receives and reformats with pretty-printing
  3. Client re-signs: { "name": "Alice", "age": 30 }
  4. Signatures don’t match, verification fails

Even though the data is identical.

JSON Canonicalization Scheme (JCS)

RFC 8785 defines canonical JSON:

Rules:

  1. No whitespace outside strings
  2. Keys sorted lexicographically
  3. Unicode characters escaped consistently
  4. Numbers in standard form (no leading zeros, scientific notation)

Example transformation:

Before (non-canonical):

1
2
3
4
5
{
  "numbers": [1.0, 2.00, 3e2],
  "name": "Alice",
  "age": 30
}

After (canonical):

1
{"age":30,"name":"Alice","numbers":[1,2,300]}

Implementing Canonicalization

Node.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const canonicalize = require('canonicalize');

const data = {
  numbers: [1.0, 2.00, 3e2],
  name: "Alice",
  age: 30
};

// Canonical form
const canonical = canonicalize(data);
console.log(canonical);
// {"age":30,"name":"Alice","numbers":[1,2,300]}

// Sign canonical form
const signature = crypto
  .createHmac('sha256', secret)
  .update(canonical)
  .digest('base64');

Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import json
import hmac
import hashlib

def canonicalize(obj):
    return json.dumps(
        obj,
        ensure_ascii=False,
        separators=(',', ':'),
        sort_keys=True
    )

data = {
    'numbers': [1.0, 2.00, 3e2],
    'name': 'Alice',
    'age': 30
}

canonical = canonicalize(data)
print(canonical)
# {"age":30,"name":"Alice","numbers":[1.0,2.0,300.0]}

signature = hmac.new(
    secret.encode(),
    canonical.encode(),
    hashlib.sha256
).hexdigest()

Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import (
	"encoding/json"
	"sort"
)

func canonicalize(data interface{}) ([]byte, error) {
	// Convert to map for key sorting
	bytes, err := json.Marshal(data)
	if err != nil {
		return nil, err
	}
	
	var obj map[string]interface{}
	if err := json.Unmarshal(bytes, &obj); err != nil {
		return nil, err
	}
	
	// Marshal with sorted keys (Go's json.Marshal sorts automatically)
	return json.Marshal(obj)
}
Best Practice: Always canonicalize JSON before signing. Libraries like JWT handle this internally, but for custom signing schemes, explicit canonicalization prevents signature mismatches from benign formatting changes.

Common Attacks and Vulnerabilities

1. Algorithm Confusion (Critical)

The attack: Attacker changes algorithm from RS256 (asymmetric) to HS256 (symmetric) in header.

Vulnerable code:

1
2
// VULNERABLE - trusts algorithm from token
const decoded = jwt.verify(token, publicKey);

Why it works:

  1. Token header says "alg": "HS256"
  2. Library uses HS256 (HMAC) with public key as secret
  3. Attacker knows the public key (it’s public!)
  4. Attacker creates valid HMAC signature
  5. Token verifies successfully

Attack example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Attacker changes header
const header = { "alg": "HS256", "typ": "JWT" };
const payload = { "sub": "admin", "role": "superuser" };

// Signs with public key as HMAC secret
const signature = hmacSha256(
  base64url(header) + '.' + base64url(payload),
  publicKey
);

const maliciousToken = base64url(header) + '.' + 
                       base64url(payload) + '.' + 
                       signature;

// Server verifies with public key - passes!

Fix:

1
2
3
4
// SECURE - specify allowed algorithms
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // Explicitly allow only RS256
});

Go:

1
2
3
4
5
6
7
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
	// Verify algorithm
	if token.Method.Alg() != "RS256" {
		return nil, fmt.Errorf("unexpected algorithm: %v", token.Header["alg"])
	}
	return publicKey, nil
})

2. None Algorithm Attack

The attack: Set algorithm to none, remove signature.

Malicious token:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.
eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJzdXBlcnVzZXIifQ.

Header: {"alg":"none","typ":"JWT"} Payload: {"sub":"admin","role":"superuser"} Signature: (empty)

Vulnerable code:

1
2
// VULNERABLE
const decoded = jwt.verify(token, secret);

If library doesn’t explicitly reject none, token passes verification.

Fix:

1
2
3
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256', 'RS256']  // Explicitly list - excludes 'none'
});

3. Weak Secrets

Vulnerable:

1
2
const secret = 'secret';  // 6 characters
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });

Attack: Brute force the secret in seconds.

Fix:

1
2
// Use cryptographically random secret, minimum 256 bits
const secret = crypto.randomBytes(32).toString('hex');

Generate secure secrets:

1
2
3
4
5
# 256-bit secret (64 hex characters)
openssl rand -hex 32

# Or base64
openssl rand -base64 32

4. Missing Expiration Check

Vulnerable:

1
2
3
4
{
  "sub": "user-123",
  "name": "Alice"
}

No exp claim - token never expires.

Fix:

1
2
3
const token = jwt.sign(payload, secret, {
  expiresIn: '15m'  // Short-lived tokens
});

Verify expiration:

1
2
const decoded = jwt.verify(token, secret);
// Library automatically checks 'exp' claim

5. Injection Attacks

SQL Injection via JWT claims:

Vulnerable code:

1
2
3
4
5
const decoded = jwt.verify(token, secret);

// VULNERABLE - unsanitized input
const query = `SELECT * FROM users WHERE id = '${decoded.sub}'`;
db.query(query);

Attack payload:

1
2
3
4
{
  "sub": "1' OR '1'='1",
  "name": "Alice"
}

Fix:

1
2
3
// Use parameterized queries
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [decoded.sub]);

6. Timing Attacks

Vulnerable signature comparison:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function verifySignature(provided, expected) {
  // VULNERABLE - early exit on mismatch
  if (provided.length !== expected.length) {
    return false;
  }
  for (let i = 0; i < provided.length; i++) {
    if (provided[i] !== expected[i]) {
      return false;  // Exits early
    }
  }
  return true;
}

Attacker measures response time to guess signature byte-by-byte.

Fix - constant-time comparison:

1
2
3
4
5
6
7
8
const crypto = require('crypto');

function verifySignature(provided, expected) {
  return crypto.timingSafeEqual(
    Buffer.from(provided),
    Buffer.from(expected)
  );
}

Go:

1
2
3
4
5
import "crypto/subtle"

func verifySignature(provided, expected []byte) bool {
	return subtle.ConstantTimeCompare(provided, expected) == 1
}

7. JWK Injection

Attack: Embed malicious public key in token header.

Malicious token header:

1
2
3
4
5
6
7
8
{
  "alg": "RS256",
  "jwk": {
    "kty": "RSA",
    "n": "attacker's-public-key-modulus",
    "e": "AQAB"
  }
}

Vulnerable code:

1
2
3
4
// VULNERABLE - trusts key from token
const header = JSON.parse(base64Decode(tokenParts[0]));
const publicKey = header.jwk;
jwt.verify(token, publicKey);

Fix:

1
2
3
4
5
// SECURE - use pre-configured keys only
const trustedPublicKey = loadKeyFromConfig();
jwt.verify(token, trustedPublicKey, {
  algorithms: ['RS256']
});

8. Token Substitution

Attack: Replace entire token with one for different user.

Scenario:

  1. Attacker obtains valid token for their account
  2. Attacker sends their token when acting as victim
  3. Server validates signature (correct for attacker’s token)
  4. Server uses claims without checking token owner

Vulnerable code:

1
2
3
4
5
6
7
app.get('/api/users/:userId', (req, res) => {
  const decoded = jwt.verify(token, secret);
  
  // VULNERABLE - doesn't check token subject matches userId
  const user = db.findUser(req.params.userId);
  res.json(user);
});

Fix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
app.get('/api/users/:userId', (req, res) => {
  const decoded = jwt.verify(token, secret);
  
  // SECURE - verify token subject matches requested resource
  if (decoded.sub !== req.params.userId) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  const user = db.findUser(req.params.userId);
  res.json(user);
});

Critical Checks:

  • Always specify allowed algorithms explicitly
  • Reject none algorithm
  • Use strong secrets (256+ bits)
  • Always include and check expiration
  • Validate claims match authorization context
  • Use constant-time comparisons
  • Never trust keys from token headers

Best Practices

1. Use Short-Lived Tokens

1
2
3
4
5
6
7
8
9
// Access token - short-lived
const accessToken = jwt.sign(payload, secret, {
  expiresIn: '15m'
});

// Refresh token - longer-lived, stored securely
const refreshToken = jwt.sign({ sub: userId }, secret, {
  expiresIn: '7d'
});

Pattern:

  • Access token: 5-15 minutes
  • Refresh token: Days to weeks
  • Refresh token rotates on use

2. Include Audience and Issuer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const token = jwt.sign(payload, secret, {
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  expiresIn: '15m'
});

// Verify matches expected values
jwt.verify(token, secret, {
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com'
});

3. Rotate Keys Regularly

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Store multiple keys with key IDs
const keys = {
  'key-2024-01': 'secret-key-1',
  'key-2024-02': 'secret-key-2'
};

// Sign with current key
const token = jwt.sign(payload, keys['key-2024-02'], {
  algorithm: 'HS256',
  keyid: 'key-2024-02'
});

// Verify with key ID from header
function verifyWithKeyRotation(token) {
  const header = jwt.decode(token, { complete: true }).header;
  const secret = keys[header.kid];
  return jwt.verify(token, secret);
}

4. Store Tokens Securely

Browser:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// AVOID: localStorage (vulnerable to XSS)
localStorage.setItem('token', token);  // DON'T

// BETTER: HttpOnly cookie
res.cookie('token', token, {
  httpOnly: true,   // Not accessible via JavaScript
  secure: true,     // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 900000    // 15 minutes
});

Mobile apps:

  • iOS: Keychain
  • Android: Keystore
  • Never store in SharedPreferences/UserDefaults

5. Implement Token Revocation

Problem: JWTs are stateless - can’t revoke before expiration.

Solutions:

A. Token blocklist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const blocklist = new Set();

function revokeToken(jti) {
  blocklist.add(jti);
}

function verifyToken(token) {
  const decoded = jwt.verify(token, secret);
  
  if (blocklist.has(decoded.jti)) {
    throw new Error('Token revoked');
  }
  
  return decoded;
}

B. Short expiration + refresh tokens:

  • Access tokens expire quickly (15 min)
  • Revoke refresh tokens in database
  • Access tokens become invalid after 15 min

C. Token versioning:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Store user's token version
const user = {
  id: 123,
  tokenVersion: 5
};

// Include in JWT
const token = jwt.sign({
  sub: user.id,
  tokenVersion: user.tokenVersion
}, secret);

// Verify version matches
function verifyToken(token) {
  const decoded = jwt.verify(token, secret);
  const user = db.findUser(decoded.sub);
  
  if (decoded.tokenVersion !== user.tokenVersion) {
    throw new Error('Token invalidated');
  }
  
  return decoded;
}

// Revoke all user's tokens
function revokeAllUserTokens(userId) {
  db.incrementTokenVersion(userId);
}

6. Use Refresh Token Rotation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
app.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  try {
    // Verify refresh token
    const decoded = jwt.verify(refreshToken, refreshSecret);
    
    // Check if token used before (reuse detection)
    const storedToken = await db.getRefreshToken(decoded.jti);
    if (!storedToken) {
      // Token already used - possible attack
      await db.revokeAllUserTokens(decoded.sub);
      return res.status(403).json({ error: 'Invalid refresh token' });
    }
    
    // Revoke old refresh token
    await db.revokeRefreshToken(decoded.jti);
    
    // Issue new tokens
    const newAccessToken = jwt.sign(
      { sub: decoded.sub },
      secret,
      { expiresIn: '15m' }
    );
    
    const newRefreshToken = jwt.sign(
      { sub: decoded.sub, jti: generateJti() },
      refreshSecret,
      { expiresIn: '7d' }
    );
    
    // Store new refresh token
    await db.storeRefreshToken(newRefreshToken);
    
    res.json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken
    });
    
  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

7. Validate All Claims

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function validateToken(token) {
  const decoded = jwt.verify(token, secret, {
    algorithms: ['HS256'],
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com'
  });
  
  // Additional validation
  if (!decoded.sub) {
    throw new Error('Missing subject claim');
  }
  
  if (!decoded.roles || !Array.isArray(decoded.roles)) {
    throw new Error('Invalid roles claim');
  }
  
  // Business logic validation
  if (decoded.accountStatus !== 'active') {
    throw new Error('Account not active');
  }
  
  return decoded;
}

8. Monitor and Log

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, secret);
    
    logger.info('Token verified', {
      userId: decoded.sub,
      tokenId: decoded.jti,
      issuedAt: decoded.iat,
      expiresAt: decoded.exp
    });
    
    return decoded;
    
  } catch (err) {
    logger.warn('Token verification failed', {
      error: err.message,
      tokenHash: hashToken(token)  // Don't log full token
    });
    
    throw err;
  }
}

Real-World Examples

OAuth 2.0 with JWT

Authorization flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 1. User authorizes app
app.get('/oauth/authorize', (req, res) => {
  // Show consent screen
  res.render('authorize', {
    clientId: req.query.client_id,
    scope: req.query.scope
  });
});

// 2. Issue authorization code
app.post('/oauth/authorize', (req, res) => {
  const authCode = generateAuthCode();
  
  // Store code with user ID and client
  db.storeAuthCode(authCode, {
    userId: req.user.id,
    clientId: req.body.client_id,
    scope: req.body.scope
  });
  
  res.redirect(`${req.body.redirect_uri}?code=${authCode}`);
});

// 3. Exchange code for tokens
app.post('/oauth/token', async (req, res) => {
  const { code, client_id, client_secret } = req.body;
  
  // Verify client
  const client = await db.verifyClient(client_id, client_secret);
  if (!client) {
    return res.status(401).json({ error: 'invalid_client' });
  }
  
  // Verify authorization code
  const authData = await db.getAuthCode(code);
  if (!authData || authData.clientId !== client_id) {
    return res.status(400).json({ error: 'invalid_grant' });
  }
  
  // Delete code (one-time use)
  await db.deleteAuthCode(code);
  
  // Issue tokens
  const accessToken = jwt.sign(
    {
      sub: authData.userId,
      client_id: client_id,
      scope: authData.scope
    },
    secret,
    { expiresIn: '1h' }
  );
  
  const refreshToken = jwt.sign(
    {
      sub: authData.userId,
      client_id: client_id,
      jti: generateJti()
    },
    refreshSecret,
    { expiresIn: '30d' }
  );
  
  await db.storeRefreshToken(refreshToken);
  
  res.json({
    access_token: accessToken,
    refresh_token: refreshToken,
    token_type: 'Bearer',
    expires_in: 3600
  });
});

Microservices Authentication

API Gateway:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Gateway verifies JWT, adds claims to headers
app.use((req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  
  try {
    const decoded = jwt.verify(token, secret);
    
    // Add claims to headers for downstream services
    req.headers['X-User-ID'] = decoded.sub;
    req.headers['X-User-Email'] = decoded.email;
    req.headers['X-User-Roles'] = decoded.roles.join(',');
    
    next();
  } catch (err) {
    res.status(401).json({ error: 'Unauthorized' });
  }
});

Downstream Service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Service trusts gateway, reads claims from headers
func getUserHandler(w http.ResponseWriter, r *http.Request) {
	// Gateway already verified JWT
	userID := r.Header.Get("X-User-ID")
	email := r.Header.Get("X-User-Email")
	roles := strings.Split(r.Header.Get("X-User-Roles"), ",")
	
	// Use claims for authorization
	if !contains(roles, "admin") {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}
	
	// Process request
	user, err := db.GetUser(userID)
	// ...
}
sequenceDiagram participant Client participant Gateway participant AuthService participant UserService Client->>Gateway: Request + JWT Gateway->>Gateway: Verify JWT Gateway->>Gateway: Extract claims Gateway->>UserService: Request + Headers (User ID, Roles) UserService->>UserService: Trust headers (from gateway) UserService->>Gateway: Response Gateway->>Client: Response Note over Gateway,UserService: Internal network
No JWT re-verification needed

Mobile App Authentication

Flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 1. User logs in
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await db.verifyCredentials(email, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Issue access token
  const accessToken = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      roles: user.roles
    },
    secret,
    { expiresIn: '15m' }
  );
  
  // Issue refresh token
  const refreshToken = jwt.sign(
    { sub: user.id, jti: generateJti() },
    refreshSecret,
    { expiresIn: '90d' }  // Long-lived for mobile
  );
  
  await db.storeRefreshToken({
    token: refreshToken,
    userId: user.id,
    deviceId: req.body.deviceId
  });
  
  res.json({
    accessToken,
    refreshToken,
    expiresIn: 900
  });
});

// 2. Mobile app stores tokens securely
// iOS: Keychain, Android: Keystore

// 3. App uses access token for requests
// Authorization: Bearer <accessToken>

// 4. When access token expires, refresh
app.post('/api/auth/refresh', async (req, res) => {
  const { refreshToken, deviceId } = req.body;
  
  try {
    const decoded = jwt.verify(refreshToken, refreshSecret);
    
    // Verify refresh token in database
    const stored = await db.getRefreshToken(decoded.jti);
    if (!stored || stored.deviceId !== deviceId) {
      throw new Error('Invalid refresh token');
    }
    
    // Issue new access token
    const newAccessToken = jwt.sign(
      {
        sub: decoded.sub,
        email: stored.email,
        roles: stored.roles
      },
      secret,
      { expiresIn: '15m' }
    );
    
    res.json({
      accessToken: newAccessToken,
      expiresIn: 900
    });
    
  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Conclusion: Security Through Modularity

We’ve completed our journey through the JSON ecosystem. From JSON’s origins through validation, performance, protocols, streaming, and now security - each part demonstrated the same architectural principle: incompleteness enables modularity.

The Complete Picture

JSON’s architecture:

  • Minimal core - Six data types, simple syntax
  • No built-in features - No validation, binary, streaming, protocols, security
  • Modular solutions - Each gap filled independently

The ecosystem response:

GapModular SolutionBenefit
No validationJSON SchemaValidates without changing parsers
No binaryJSONB, BSON, MessagePackChoose efficiency per use case
No streamingJSON LinesEnables constant-memory processing
No protocolJSON-RPCAdds structure without complexity
No securityJWT, JWS, JWEComposable cryptographic protection

Why This Succeeded

XML’s approach:

  • Built-in validation (XSD)
  • Built-in signatures (XML Signature)
  • Built-in encryption (XML Encryption)
  • Built-in transformation (XSLT)
  • Result: Monolithic, complex, rigid

JSON’s approach:

  • External validation (JSON Schema)
  • External signing (JWS)
  • External encryption (JWE)
  • External protocols (JSON-RPC)
  • Result: Modular, simple, adaptable
The Architectural Lesson: Incompleteness isn’t weakness when you design for modularity. JSON’s success came from staying minimal and letting the ecosystem build composable solutions. Each layer can evolve independently - JWT updates don’t break JSON parsers, new binary formats don’t require schema changes, streaming conventions don’t impact existing APIs.

JSON Security: The Modular Approach Complete

With JWT, JWS, and JWE, we’ve seen how JSON’s security layer follows the same pattern as every other part of this series:

The gap: JSON has no authentication, encryption, or signing primitives.

The solution: Separate, composable standards (JWT, JWS, JWE) that work with any transport.

The benefit: Each evolves independently. JWT improvements don’t break JSON parsers. New signing algorithms don’t require format changes. Security practices advance without coordinated ecosystem updates.

The trade-off: Flexibility requires knowledge. Developers must understand algorithm confusion attacks, token substitution, timing vulnerabilities. XML’s bundled security was harder to get started but forced awareness. JSON’s modular security is easier to adopt but easier to get wrong.

This completes our technical journey through the JSON ecosystem. But there’s a deeper story here about why JSON succeeded, what it teaches us about technology evolution, and the hidden costs of modularity.

Continue to Part 8: Lessons from the JSON Revolution - Explore the architectural zeitgeist, the JSX vindication, and what JSON teaches us about technology evolution beyond data formats.

Security Best Practices Summary

Essential practices:

  1. Always specify allowed algorithms explicitly
  2. Use short-lived access tokens (15 minutes or less)
  3. Implement refresh token rotation
  4. Store tokens securely (HttpOnly cookies, Keychain, Keystore)
  5. Validate all claims (exp, iss, aud, sub)
  6. Use strong secrets (256+ bits, cryptographically random)
  7. Enable token revocation mechanisms
  8. Monitor and log authentication events
  9. Use TLS for transport security
  10. Consider JWE for sensitive payloads

Critical vulnerabilities to avoid:

  • Algorithm confusion (RS256 → HS256)
  • None algorithm acceptance
  • Weak or hardcoded secrets
  • Missing expiration checks
  • Trusting JWK from token headers
  • Non-constant-time comparisons
  • SQL injection via claims
  • Token substitution attacks

The Technical Series Complete

What we’ve learned:

  • Part 1: JSON’s triumph through simplicity
  • Part 2: Validation with JSON Schema
  • Part 3: Binary JSON in databases (JSONB, BSON)
  • Part 4: Binary JSON for APIs (MessagePack, CBOR)
  • Part 5: Protocols with JSON-RPC
  • Part 6: Streaming with JSON Lines
  • Part 7: Security with JWT/JWS/JWE

Each part showed the same pattern: identify incompleteness, build modular solution, maintain JSON’s core simplicity.

But there’s a deeper question: Why did this approach succeed where XML’s integrated approach failed?

Continue to Part 8: Lessons from the JSON Revolution - The final part explores the meta-patterns: how technologies reflect their era’s architectural zeitgeist, why good patterns survive regardless of packaging (JSX vindication), and the hidden costs of modularity through ecosystem fragmentation.

Not just about JSON anymore - Part 8 examines what JSON teaches us about technology evolution, architectural thinking, and why “better” technologies don’t always win.


Further Reading

Specifications:

Security Resources:

Libraries:

Related Articles: