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

JWS (JSON Web Signature) — An IETF standard (RFC 7515) that defines how to represent content secured with a digital signature or MAC using JSON-based data structures, in either compact or JSON serialization.
JOSE (JSON Object Signing and Encryption) — The umbrella framework covering JWS (signing), JWE (encryption), JWK (key format), and JWA (algorithms). JWS and JWE together implement the signing and confidentiality halves of JOSE.
Compact Serialization — The URL-safe dot-delimited token format header.payload.signature used in HTTP Authorization headers and cookies. Supports exactly one signature; not extensible for multiple signers.
alg:none attack — A vulnerability where an attacker sets the JOSE header {"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.
EdDSA (Edwards-curve Digital Signature Algorithm) — A modern signature scheme using twisted Edwards curves; the Ed25519 variant offers 128-bit security, compact 64-byte signatures, fast constant-time operations, and resistance to common side-channel attacks.

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.

PropertyJWSJWT
RFC75157519
Payload typeAny bytesJSON Claims Set
Typical useSigned webhooks, documentsAPI authentication, SSO
Claim validationNot specifiedexp, nbf, iss, aud required
Is a subset ofJOSE frameworkJWS (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  // signature

Decoded 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 alg

Base64url 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.

ParameterRequiredDescription
algYesSigning algorithm: HS256, RS256, ES256, EdDSA, none, etc.
typNoMedia type hint; "JWT" for JWT tokens
kidNoKey ID — identifies which key was used; essential for key rotation
critNoArray of header names the recipient MUST understand and process
jkuNoURL of JWK Set containing the signing key; validate the URL is trusted
jwkNoEmbedded public JWK; never trust for authentication without allowlisting
x5cNoX.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)

AlgorithmTypeSig sizeKey requirementBest for
HS256Symmetric32 bytesShared secret ≥ 256 bitsSingle-party or fully trusted verifier
RS256Asymmetric256 bytes (RSA-2048)RSA key pair ≥ 2048 bitsOpenID Connect, widespread compatibility
ES256Asymmetric64 bytesECDSA P-256 key pairMobile, IoT, size-constrained environments
EdDSAAsymmetric64 bytesEd25519 key pairNew 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 algorithms allowlist 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 kid for rotation
  • Validate exp, nbf, iss, and aud claims after signature verification
  • Never trust jku or jwk header 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