JSON Web Signature (JWS): How It Works
Last updated:
JSON Web Signature (JWS) is the JOSE standard that cryptographically signs a payload so any recipient can verify it was produced by a specific key and has not been tampered with. It underpins JSON Web Tokens (JWT), signed webhooks, document integrity checks, and API request signing. This guide covers the full JWS lifecycle: structure, JOSE header semantics, algorithm selection, the Node.js jose library, JSON Serialization for multi-party signing, and the critical security pitfalls every implementer must know.
Key Terms
header.payload.signature used in HTTP Authorization headers and cookies. Supports exactly one signature; not extensible for multiple signers.{"alg"} field to "none" and strips the signature, tricking a naive verifier into accepting a tampered token as valid because the "unsecured JWS" spec allows an empty signature.JWS vs JWT: What's the Difference
JWS is the general-purpose signing container. It can sign any payload — JSON, binary, XML, or arbitrary bytes. JWT is a profile of JWS that constrains the payload to be a Base64url-encoded JSON object containing standardized claims (sub, iss, exp, iat, aud, etc.) as defined in RFC 7519.
| Property | JWS | JWT |
|---|---|---|
| RFC | 7515 | 7519 |
| Payload type | Any bytes | JSON Claims Set |
| Typical use | Signed webhooks, documents | API authentication, SSO |
| Claim validation | Not specified | exp, nbf, iss, aud required |
| Is a subset of | JOSE framework | JWS (compact serialization) |
The practical implication: when building API authentication, use a JWT library that handles claim validation. When signing arbitrary payloads (webhook bodies, documents, API request parameters), use a JWS library directly.
JWS Compact Serialization Structure
The compact serialization encodes the signed message as three Base64url strings separated by dots:
BASE64URL(UTF8(JWS Protected Header))
. BASE64URL(JWS Payload)
. BASE64URL(JWS Signature)
// Concrete example (HS256, JWT payload)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // header
.eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTcxNjEyMzQ1NiwiZXhwIjoxNzE2MTI3MDU2fQ== // payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // signatureDecoded header and payload:
// Header (JOSE Protected Header)
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (JWS Payload — for JWT, a Claims Set)
{
"sub": "user_123",
"iat": 1716123456,
"exp": 1716127056
}
// The signature is computed over:
// ASCII( BASE64URL(header) + "." + BASE64URL(payload) )
// using the algorithm declared in algBase64url is standard Base64 with + replaced by - and / replaced by _, with padding (=) omitted. This makes the token safe in URLs, query strings, and HTTP headers without additional encoding.
The signature covers the concatenation BASE64URL(header) + "." + BASE64URL(payload), not the raw JSON objects. This means even a single byte change to either part invalidates the signature, providing tamper detection across both header metadata and payload content.
JOSE Header Fields
The JOSE Protected Header is a JSON object Base64url-encoded into the first part of the compact serialization. Its fields control algorithm selection, key identification, and processing constraints.
| Parameter | Required | Description |
|---|---|---|
alg | Yes | Signing algorithm: HS256, RS256, ES256, EdDSA, none, etc. |
typ | No | Media type hint; "JWT" for JWT tokens |
kid | No | Key ID — identifies which key was used; essential for key rotation |
crit | No | Array of header names the recipient MUST understand and process |
jku | No | URL of JWK Set containing the signing key; validate the URL is trusted |
jwk | No | Embedded public JWK; never trust for authentication without allowlisting |
x5c | No | X.509 certificate chain; used in PKI-based JOSE deployments |
// Minimal header — HS256 token
{ "alg": "HS256" }
// Production RS256 header with key ID
{ "alg": "RS256", "typ": "JWT", "kid": "rsa-key-2026-01" }
// EdDSA header with custom claim requirement
{ "alg": "EdDSA", "typ": "JWT", "kid": "ed25519-key-1", "crit": ["exp"] }Signing Algorithms Compared (HS256, RS256, ES256, EdDSA)
| Algorithm | Type | Sig size | Key requirement | Best for |
|---|---|---|---|---|
HS256 | Symmetric | 32 bytes | Shared secret ≥ 256 bits | Single-party or fully trusted verifier |
RS256 | Asymmetric | 256 bytes (RSA-2048) | RSA key pair ≥ 2048 bits | OpenID Connect, widespread compatibility |
ES256 | Asymmetric | 64 bytes | ECDSA P-256 key pair | Mobile, IoT, size-constrained environments |
EdDSA | Asymmetric | 64 bytes | Ed25519 key pair | New systems, best speed + security |
HS256 requires every verifier to hold the signing secret, creating key-distribution risk. Use it only when signer and verifier are the same service or within a fully trusted boundary.
RS256 remains the most widely compatible choice — virtually every OAuth 2.0 and OIDC library supports it, and public keys can be distributed via JWKS endpoints. The tradeoff is large signature size and relatively slow signing.
ES256 and EdDSA are preferred for new systems: compact signatures, fast constant-time operations, and strong security margins. EdDSA (Ed25519) avoids the nonce-reuse vulnerability inherent in ECDSA implementations. If your library supports it, default to EdDSA.
Signing and Verifying with jose (Node.js)
The jose npm package is the reference JOSE implementation for JavaScript/TypeScript, supporting all standard JWS algorithms in both Node.js and browser environments.
HS256 (Symmetric)
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(
process.env.JWT_SECRET // must be >= 256 bits (32 chars)
)
// Sign
const token = await new SignJWT({ sub: 'user_123', role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.setIssuer('https://api.example.com')
.setAudience('https://app.example.com')
.sign(secret)
// Verify — always specify algorithms explicitly
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'], // never allow the header to dictate
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
})
console.log(payload.sub) // "user_123"RS256 (RSA Asymmetric)
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose'
import fs from 'fs'
const privateKey = await importPKCS8(
fs.readFileSync('./private-key.pem', 'utf-8'),
'RS256'
)
const publicKey = await importSPKI(
fs.readFileSync('./public-key.pem', 'utf-8'),
'RS256'
)
// Sign with private key
const token = await new SignJWT({ sub: 'user_123' })
.setProtectedHeader({ alg: 'RS256', kid: 'rsa-key-2026-01' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(privateKey)
// Verify with public key — safe to distribute, cannot sign
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'],
})EdDSA (Ed25519 — Recommended)
import { SignJWT, jwtVerify, generateKeyPair } from 'jose'
// Generate Ed25519 key pair (do this once, then persist)
const { privateKey, publicKey } = await generateKeyPair('EdDSA', {
crv: 'Ed25519',
})
// Sign
const token = await new SignJWT({ sub: 'user_123', scope: 'read write' })
.setProtectedHeader({ alg: 'EdDSA', kid: 'ed25519-key-1' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey)
// Verify
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['EdDSA'],
})
// Sign arbitrary (non-JWT) payload with CompactSign
import { CompactSign } from 'jose'
const data = new TextEncoder().encode(JSON.stringify({ event: 'order.created', id: 'ord_42' }))
const jws = await new CompactSign(data)
.setProtectedHeader({ alg: 'EdDSA' })
.sign(privateKey)
// Verify arbitrary JWS
import { compactVerify } from 'jose'
const { payload: rawBytes } = await compactVerify(jws, publicKey, {
algorithms: ['EdDSA'],
})
const event = JSON.parse(new TextDecoder().decode(rawBytes))Verifying Remote JWKS (OAuth / OIDC)
import { createRemoteJWKSet, jwtVerify } from 'jose'
// Fetch public keys from OIDC provider's JWKS endpoint
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/openid-configuration')
)
// Verify a Google ID token — jose selects the right key by kid
const { payload } = await jwtVerify(idToken, JWKS, {
algorithms: ['RS256'], // Google uses RS256
audience: 'your-client-id.apps.googleusercontent.com',
issuer: 'https://accounts.google.com',
})JWS JSON Serialization
JSON Serialization represents a signed payload as a JSON object, enabling multiple independent signatures on the same content. Two forms exist: General JSON Serialization (multiple signers) and Flattened JSON Serialization (single signer, but richer metadata than compact).
// General JSON Serialization — multiple signers
{
"payload": "eyJvcmRlcklkIjoib3JkXzQyIiwiYW1vdW50IjoxMDB9",
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0",
"header": { "department": "finance" },
"signature": "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q"
},
{
"protected": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yIn0",
"header": { "department": "legal" },
"signature": "cC4hiUPoj9Eetdgtv3hF80EGrhuB_Vd..."
}
]
}
// Flattened JSON Serialization — single signer, richer than compact
{
"payload": "eyJzdWIiOiJ1c2VyXzEyMyJ9",
"protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImVkMjU1MTkta2V5LTEifQ",
"header": { "custom-claim": "value" },
"signature": "FW_tkv4UGpHB..."
}import { GeneralSign, generalVerify } from 'jose'
const payload = new TextEncoder().encode(
JSON.stringify({ orderId: 'ord_42', amount: 100 })
)
// Sign with two keys
const jws = await new GeneralSign(payload)
.addSignature(financePrivateKey)
.setProtectedHeader({ alg: 'ES256', kid: 'key-1' })
.setUnprotectedHeader({ department: 'finance' })
.addSignature(legalPrivateKey)
.setProtectedHeader({ alg: 'RS256', kid: 'key-2' })
.setUnprotectedHeader({ department: 'legal' })
.sign()
// Verify (verifies all signatures; returns first successful)
const { payload: rawBytes, protectedHeader } = await generalVerify(
jws,
financePublicKey,
{ algorithms: ['ES256'] }
)JSON Serialization is appropriate for document signing workflows, multi-party approvals, and audit trails where different parties need to independently attest to the same content with their own keys. For API authentication, stick with compact serialization.
Security Pitfalls: alg:none and Key Confusion
The alg:none Attack
RFC 7515 defines "alg":"none" as a valid algorithm producing an "unsecured JWS" with an empty signature. Vulnerable libraries that accept the algorithm from the token header without restriction can be exploited:
// Attacker decodes a legitimate token:
// header: { "alg": "RS256", "typ": "JWT" }
// payload: { "sub": "user_456", "role": "user" }
// Attacker modifies and re-encodes:
// header: { "alg": "none" } <-- changed
// payload: { "sub": "user_456", "role": "admin" } <-- elevated privilege
// signature: "" <-- stripped
// Results in: eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyXzQ1NiIsInJvbGUiOiJhZG1pbiJ9.
// VULNERABLE verifier (never do this):
function verifyUnsafe(token: string, secret: string) {
const [headerB64, payloadB64] = token.split('.')
const header = JSON.parse(atob(headerB64))
if (header.alg === 'none') return JSON.parse(atob(payloadB64)) // WRONG
// ...
}
// SAFE verifier — explicit algorithm allowlist:
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // rejects "none", "HS256", and everything else
})The Key Confusion (Algorithm Confusion) Attack
// Setup: Server signs RS256 tokens with privateKey; verifies with publicKey.
// The publicKey is available to anyone (JWKS endpoint, certificate, etc.)
// Attack:
// 1. Attacker downloads the server's RSA public key (PEM format)
// 2. Uses that public key *as an HMAC secret* to sign a malicious HS256 token
// 3. Sets header { "alg": "HS256" }
// VULNERABLE verifier (reads alg from header):
function verifyUnsafe(token: string, rsaPublicKey: string) {
const { alg } = decodeHeader(token)
if (alg === 'HS256') return hmacVerify(token, rsaPublicKey) // CATASTROPHIC
if (alg === 'RS256') return rsaVerify(token, rsaPublicKey)
}
// SAFE: always pin the expected algorithm, never derive it from the token
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // HS256 attempt is immediately rejected
})
// Key rotation best practice — use kid to select the right key:
import { createLocalJWKSet, jwtVerify } from 'jose'
const jwks = createLocalJWKSet({
keys: [
{ kty: 'OKP', crv: 'Ed25519', kid: 'key-2026', x: '...' },
{ kty: 'OKP', crv: 'Ed25519', kid: 'key-2025', x: '...' }, // retired
]
})
// jose selects key by kid header, then verifies with pinned algorithm
const { payload } = await jwtVerify(token, jwks, {
algorithms: ['EdDSA'],
})Security Checklist
- Always pass an explicit
algorithmsallowlist to your verification function - Never allow the token header to dictate the algorithm; reject any algorithm not on the allowlist
- Reject tokens with
"alg":"none"unconditionally in production - For HS256: use secrets of at least 256 bits; rotate periodically; never expose the secret
- For RS256/ES256/EdDSA: publish public keys via a JWKS endpoint; use
kidfor rotation - Validate
exp,nbf,iss, andaudclaims after signature verification - Never trust
jkuorjwkheader parameters without explicit allowlisting of trusted URLs/keys - Set short expiry times (15 minutes for access tokens) and use refresh tokens for long sessions
FAQ
What is the difference between JWS and JWT?
JWS is the general signing mechanism for any payload. JWT is a specific profile of JWS where the payload is a JSON Claims Set (sub, iss, exp, etc.) as defined in RFC 7519. Every JWT in compact serialization is a JWS, but JWS can also sign arbitrary binary or non-claim JSON. Use a JWT library for authentication tokens; use a JWS library directly for signing webhook payloads or documents.
What does JWS compact serialization look like?
Three Base64url-encoded strings joined by dots: BASE64URL(header).BASE64URL(payload).BASE64URL(signature). The signature is computed over the ASCII bytes of header.payload. Base64url replaces + with - and / with _, and omits padding, making tokens URL-safe without additional encoding.
What is the difference between HS256, RS256, ES256, and EdDSA?
HS256 is symmetric (shared secret, both sides need it). RS256, ES256, and EdDSA are asymmetric (private key signs, public key verifies). RS256 uses RSA (large 256-byte signatures, broad compatibility). ES256 uses ECDSA P-256 (compact 64-byte signatures). EdDSA uses Ed25519 (compact 64-byte signatures, fastest, no nonce-reuse risk). Prefer EdDSA for new systems; use RS256 if broad OIDC compatibility is required.
What is the alg:none attack and why is it dangerous?
An attacker sets {"alg":"none"} in the token header and strips the signature. Naive verifiers that accept the algorithm from the header without restriction will treat the empty signature as valid for an "unsecured JWS", allowing payload tampering. Fix: always pass an explicit algorithm allowlist to your verifier; never accept "none" in production.
What JOSE header fields are mandatory for JWS?
Only alg is mandatory. typ is optional (conventionally "JWT" for JWT tokens). kid is strongly recommended in production for key rotation. crit lists header parameters the recipient must understand. All other registered parameters (jku, jwk, x5u, x5c) are optional and used only in specific PKI-based JOSE scenarios.
What is JWS JSON Serialization and when should I use it?
JSON Serialization represents a signed message as a JSON object rather than a dot-delimited string, and supports a signatures array for multiple independent signatures on the same payload. Use it for multi-party document signing, approval workflows, or audit trails where different parties sign the same content with their own keys. For standard API authentication, compact serialization (JWT) is simpler and sufficient.
How do I verify a JWS token without trusting the header algorithm?
Pass an explicit algorithms array to the verification function: jwtVerify(token, key, { algorithms: ["RS256"] }). This causes the library to reject any token whose header declares a different algorithm, including "none". Never pass a wildcard or derive the algorithm from the token itself. After signature verification, explicitly validate exp, nbf, iss, and aud.
What is the key confusion attack in JWS?
An attacker uses the server's public RSA key as an HMAC secret to forge an HS256-signed token. If the verifier accepts the algorithm from the token header and happens to use the public key as the HMAC secret, the forged token passes verification. Prevention: pin the expected algorithm in your verifier (algorithms: ['RS256']), never allowing the header to switch from asymmetric to symmetric algorithms.
Further reading and primary sources
- RFC 7515 JWS Spec — Official JSON Web Signature specification
- jose npm library — Universal JavaScript JOSE implementation
- JOSE Working Group — JOSE framework overview