JWT Size: Limits, Optimization, and Cookie vs Header
Last updated:
A typical JWT (header + payload + signature) with 5–6 standard claims is 200–400 bytes when Base64URL-encoded — but JWTs with many custom claims can exceed 4 KB, hitting browser cookie limits and HTTP header size limits. Browser cookies have a 4,096-byte per-cookie limit (RFC 6265); most browsers enforce this strictly. Most HTTP servers (nginx, Apache) reject headers larger than 8 KB by default, and AWS API Gateway rejects headers over 10 KB. This guide covers how to measure JWT size, the three main size constraints (cookies, headers, URL), how to slim down bloated JWTs by pruning claims, and when to switch to opaque tokens with a server-side session store. For the underlying token format, see how JWT works and how to decode a JWT token.
Need to inspect a JWT and see its exact byte size? Paste it into Jsonic's JWT Decoder — reads the header, payload, and claims instantly without sending anything to a server.
Open JWT Decoder →How to Measure Your JWT Size
A JWT is three Base64URL-encoded segments separated by dots: header.payload.signature. Because each segment uses only ASCII characters, the byte length of a JWT equals its character count — no multi-byte encoding to worry about. Measuring is straightforward in any language.
The raw JSON payload is Base64URL-encoded, which expands it by approximately 33% (every 3 bytes of input become 4 characters of output, rounded up to the next 4-character boundary, without padding). A 100-byte JSON payload becomes ~136 Base64URL characters. To understand what's inside any token, learn how Base64URL encoding works.
// ── Node.js / Deno ──────────────────────────────────────────────────────────
const token = 'eyJhbGc...' // your JWT string
// Method 1: character count (works for ASCII-only JWTs)
console.log('JWT length (chars):', token.length)
// Method 2: byte length (canonical, handles any encoding)
console.log('JWT size (bytes):', Buffer.byteLength(token, 'utf8'))
// Method 3: section-by-section breakdown
const [header, payload, signature] = token.split('.')
console.log('Header :', header.length, 'chars')
console.log('Payload :', payload.length, 'chars')
console.log('Sig :', signature.length, 'chars')
console.log('Total :', token.length, 'chars (+ 2 dots)')
// Decode payload to see raw claims JSON before encoding
const rawJson = Buffer.from(payload, 'base64url').toString('utf8')
const claims = JSON.parse(rawJson)
console.log('Claims JSON size:', rawJson.length, 'bytes')
console.log('Claims:', claims)
// ── Python ───────────────────────────────────────────────────────────────────
# import base64, json
# token = 'eyJhbGc...'
# print('JWT size (bytes):', len(token.encode('utf-8')))
# _, payload_b64, _ = token.split('.')
# padding = '=' * (-len(payload_b64) % 4)
# raw = base64.urlsafe_b64decode(payload_b64 + padding)
# claims = json.loads(raw)
// ── Browser ──────────────────────────────────────────────────────────────────
// const size = new TextEncoder().encode(token).length
// console.log('JWT size (bytes):', size)Algorithm-specific signature sizes are fixed: HS256 signatures are always 32 bytes (43 Base64URL characters); RS256 signatures with a 2048-bit key are always 256 bytes (342 Base64URL characters). The header for {"alg":"HS256","typ":"JWT"} is always 36 Base64URL characters. Variable size lives entirely in the payload — which is why claim pruning is the most effective size reduction technique. See the JWT claims reference for the standard registered claim set.
HTTP Header Size Limits: nginx, Apache, AWS, Cloudflare
When a JWT is sent in the Authorization: Bearer <token>header, its size counts against the HTTP server's request header buffer limits. These limits vary significantly by server and are often the first place large JWTs break in production.
| Server / Service | Default header limit | Configurable? | Error returned |
|---|---|---|---|
| nginx | 4 × 8 KB buffers (per header line: 8 KB) | Yes — large_client_header_buffers | HTTP 400 / 431 |
| Apache httpd | 8,190 bytes per header field | Yes — LimitRequestFieldSize | HTTP 400 |
| AWS API Gateway | 10 KB per header, 10 KB total | No — hard limit | HTTP 431 |
| AWS ALB | 16 KB per header | No — hard limit | HTTP 400 |
| Cloudflare | 32 KB total request headers | No — hard limit | HTTP 431 |
| Node.js (http module) | 16 KB default (--max-http-header-size) | Yes — CLI flag or maxHeaderSize | HTTP 431 |
# nginx — increase header buffer for large JWTs (nginx.conf or server block)
# Default: large_client_header_buffers 4 8k
# Increase to 16k if your JWT regularly approaches 8 KB:
http {
large_client_header_buffers 4 16k;
# Each of the 4 buffers can now hold a single header line up to 16 KB.
# The Authorization: Bearer <token> header must fit in one buffer.
}
# Apache — increase per-field limit (httpd.conf or .htaccess)
# Default: LimitRequestFieldSize 8190
LimitRequestFieldSize 16380
# Node.js — increase at startup (not recommended as a long-term fix)
# node --max-http-header-size=16384 server.jsIncreasing server limits is a short-term workaround, not a solution. If your JWT consistently exceeds 4 KB, the token design needs to change — not the infrastructure configuration. A 10 KB JWT means the token is carrying data that belongs in a database.
JWT in URLs: Query String Size Limits
Passing JWTs in URL query strings (e.g., ?token=eyJhbGc...) is occasionally done for magic-link authentication or email verification flows. The URL length limits you will encounter are browser and server-side, and they vary widely.
The HTTP specification (RFC 7230) does not define a maximum URL length, but servers and browsers impose their own limits. Internet Explorer famously limited URLs to 2,083 characters; modern browsers (Chrome, Firefox, Safari) support URLs of 64,000+ characters. However, servers are more restrictive: nginx defaults to a 8 KB limit on the request URI via large_client_header_buffers (the same buffer used for all headers), and many load balancers cap URIs at 4 KB or 8 KB. In practice, a URL-safe JWT should stay under 2,000 characters to be compatible with all intermediate infrastructure.
// ── Checking JWT length before embedding in URL ──────────────────────────
const token = 'eyJhbGc...'
const url = `https://example.com/verify?token=${encodeURIComponent(token)}`
// encodeURIComponent does NOT Base64URL-encode the token again —
// it percent-encodes unsafe URL characters. A JWT already uses only
// Base64URL chars (A-Z, a-z, 0-9, -, _) plus dots, which are all URL-safe.
// So encodeURIComponent on a JWT mainly encodes the dots as %2E — adding ~6 chars.
console.log('URL length:', url.length)
if (url.length > 2000) {
console.warn('URL exceeds 2000 chars — may break on some infrastructure')
}
// ── URL shortener pattern ──────────────────────────────────────────────────
// For email verification, store the JWT server-side with a short random key.
// Email the short URL. User clicks it; server looks up the JWT by key.
import crypto from 'crypto'
import { redis } from './redis'
async function createShortMagicLink(jwt: string): Promise<string> {
const key = crypto.randomBytes(16).toString('base64url') // ~22 chars
// Store JWT with a 1-hour TTL — single-use after verification
await redis.set(`magic:${key}`, jwt, { EX: 3600 })
return `https://example.com/verify?k=${key}`
// Resulting URL is always under 60 characters, regardless of JWT size
}
async function verifyShortLink(key: string): Promise<string | null> {
const jwt = await redis.get(`magic:${key}`)
if (jwt) await redis.del(`magic:${key}`) // single-use
return jwt
}The URL shortener pattern (storing the JWT server-side and emailing a short random key) is the correct approach for magic links — it limits token exposure in email clients, browser history, and server logs, while keeping URLs short and infrastructure-compatible. This is a case where an opaque token pattern outperforms JWTs in URLs regardless of size.
Slim Down JWT Claims: Pruning, Abbreviating, and Restructuring
The most effective way to reduce JWT size is to put less data in the payload. Every byte saved in the raw JSON payload saves approximately 1.33 bytes in the Base64URL-encoded token. The following techniques, applied in order, will reduce most bloated JWTs to under 1 KB.
Step 1: Audit claims for actual usage. Print your JWT payload in a staging environment and ask: does every receiving service actually read this claim for an authorization decision? Claims added "in case they are needed" are a common source of bloat. Remove any claim that no downstream service uses.
Step 2: Abbreviate long claim names. Claim names are included verbatim in the JSON, so shorter names save bytes. Since both issuer and verifier are your own systems, you can define any naming convention. Common abbreviations:
// ❌ Verbose claim names — 187 bytes of JSON
{
"subject": "usr_01HX4Y...",
"department": "engineering",
"permissions": ["read:orders", "write:orders"],
"organization_id": "org_01HX...",
"subscription_tier": "enterprise"
}
// ✅ Abbreviated claim names — 112 bytes of JSON (40% smaller)
{
"sub": "usr_01HX4Y...", // standard registered claim — always use short form
"dept": "eng", // abbreviate value too when a code suffices
"prm": ["ro", "wo"], // abbreviate both name and values
"org": "org_01HX...",
"tier": "ent"
}
// ── Move roles to DB, reference by user ID ─────────────────────────────────
// Instead of embedding a roles array in the token:
// { "roles": ["admin", "billing_manager", "report_viewer", "support_agent"] }
//
// Include only the user ID. Fetch roles from a Redis cache on first request.
async function getRoles(userId: string): Promise<string[]> {
const cached = await redis.get(`roles:${userId}`)
if (cached) return JSON.parse(cached)
const roles = await db.query(
'SELECT role FROM user_roles WHERE user_id = $1', [userId]
)
const roleList = roles.rows.map(r => r.role)
await redis.set(`roles:${userId}`, JSON.stringify(roleList), { EX: 300 })
return roleList
}
// ── Replace permission arrays with OAuth 2.0 scope strings ────────────────
// Instead of: { "permissions": ["read:orders", "write:orders", "read:invoices"] }
// Use: { "scope": "orders:rw invoices:r" }
// Space-separated scope string is the OAuth 2.0 standard (RFC 6749 §3.3)
// and is far more compact than a JSON array of strings.Step 3: Use opaque IDs, not readable strings. "org": "acme-corp-inc" uses 14 bytes for the value; "org": "org_01HX4Y8K" uses 12 bytes and leaks no information about the organization name. Opaque IDs are both smaller and more secure.
Step 4: Remove redundant or derivable claims. If the iss claim already identifies your auth server, you may not need a separate tenant claim if your system has only one tenant per issuer URL. Every claim that can be derived server-side from other available information is a candidate for removal.
When to Switch to Opaque Tokens
An opaque token is a cryptographically random string — typically 32 bytes of entropy encoded as 43 Base64URL characters — that serves as a key into a server-side session store (Redis, PostgreSQL). It carries no claims; the server looks up the session data on every request. This is the architecture behind traditional cookie-based sessions.
Switch from JWTs to opaque tokens when any of the following are true: your JWT consistently exceeds 1 KB after applying the claim-pruning techniques above; you need immediate revocation without a blocklist; you need to update session data (e.g., user changed their plan) without reissuing a token; or you are hitting a hard infrastructure limit (AWS API Gateway's 10 KB header cap) that you cannot work around.
import crypto from 'crypto'
import { redis } from './redis'
// ── Opaque token: issue ────────────────────────────────────────────────────
async function createSession(userId: string, metadata: Record<string, unknown>) {
// 32 bytes = 256 bits of entropy — cryptographically secure
const sessionId = crypto.randomBytes(32).toString('base64url') // 43 chars
const sessionData = {
userId,
...metadata,
createdAt: Date.now(),
expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes
}
// Store in Redis with TTL — auto-expires, no cleanup needed
await redis.set(
`session:${sessionId}`,
JSON.stringify(sessionData),
{ EX: 15 * 60 } // 15-minute TTL in seconds
)
return sessionId // 43-char string — fits easily in any cookie or header
}
// ── Opaque token: verify ───────────────────────────────────────────────────
async function verifySession(sessionId: string) {
const raw = await redis.get(`session:${sessionId}`)
if (!raw) throw new Error('Session not found or expired')
const session = JSON.parse(raw)
if (session.expiresAt < Date.now()) {
await redis.del(`session:${sessionId}`)
throw new Error('Session expired')
}
return session // { userId, roles, orgId, ... } — full data, any size
}
// ── Opaque token: revoke instantly ─────────────────────────────────────────
async function revokeSession(sessionId: string) {
await redis.del(`session:${sessionId}`)
// Effective immediately — no TTL to wait for, no blocklist needed
}
// ── Opaque token: update session data without reissuing token ──────────────
async function updateSessionData(
sessionId: string,
updates: Record<string, unknown>
) {
const raw = await redis.get(`session:${sessionId}`)
if (!raw) throw new Error('Session not found')
const session = { ...JSON.parse(raw), ...updates }
const remainingTtl = Math.floor((session.expiresAt - Date.now()) / 1000)
await redis.set(`session:${sessionId}`, JSON.stringify(session), {
EX: Math.max(remainingTtl, 1),
})
}The trade-off is a Redis lookup on every authenticated request: typically 0.5–2 ms on a co-located Redis instance, 5–20 ms across availability zones. For most web applications, this latency is acceptable. For edge functions or globally distributed systems that require zero-latency stateless verification, JWTs remain the right choice — just keep them compact. See the JWT best practices guide for the full comparison of stateless vs. stateful token architectures.
JWT Compression: DEFLATE in JWE and Its Limitations
RFC 7516 (JSON Web Encryption, JWE) supports DEFLATE compression of the plaintext payload before encryption. This is the only standardized form of JWT compression, and it only makes sense inside a JWE — not in a standard signed JWT (JWS).
Here is why compression on unencrypted JWTs does not help much: the Base64URL encoding step that follows compression expands the compressed bytes by 33%. For short payloads (under 1 KB), the compression gain is typically less than the Base64URL expansion overhead. Compression only yields meaningful size reductions for payloads larger than 1–2 KB — which is exactly the range where you should be questioning the token design rather than trying to compress it.
import { deflateRawSync, inflateRawSync } from 'zlib'
import { SignJWT, jwtVerify, CompactEncrypt, compactDecrypt } from 'jose'
// ── JWE with DEFLATE compression (RFC 7516 zip:"DEF") ─────────────────────
// This is the ONLY standardized JWT compression approach.
// Requires encryption (JWE) — not applicable to plain signed JWTs (JWS).
const encoder = new TextEncoder()
// Compress + encrypt payload
const payload = JSON.stringify({
sub: 'usr_01HX4Y...',
roles: ['admin', 'billing', 'support'],
// ... many more claims
})
const compressed = deflateRawSync(encoder.encode(payload))
const jwe = await new CompactEncrypt(compressed)
.setProtectedHeader({
alg: 'RSA-OAEP-256', // key wrapping algorithm
enc: 'A256GCM', // content encryption algorithm
zip: 'DEF', // DEFLATE compression — RFC 7516 §4.1.3
})
.encrypt(recipientPublicKey)
// ── Why NOT to compress plain signed JWTs ─────────────────────────────────
// Demonstration: compression + Base64URL often yields minimal gain
const claims = JSON.stringify({ sub: 'usr_01', iss: 'https://auth.example.com',
aud: 'api.example.com', exp: 1747728000, iat: 1747727100, jti: 'uuid-here',
roles: ['admin', 'editor', 'viewer'] })
const original = Buffer.byteLength(claims, 'utf8')
const compressed2 = deflateRawSync(Buffer.from(claims))
const b64UrlLen = Math.ceil(compressed2.length / 3) * 4 // Base64URL length
console.log('Original JSON :', original, 'bytes')
console.log('Compressed :', compressed2.length, 'bytes')
console.log('After Base64URL:', b64UrlLen, 'bytes')
// For small payloads, b64UrlLen is often LARGER than original
// ── Custom header compression (non-standard, not recommended) ─────────────
// Some teams add a custom 'zip' header to JWS tokens and compress + Base64URL
// the payload themselves. This is NOT RFC-standardized and requires custom
// handling on both issuer and verifier. Avoid unless you control the full stack
// and have measured a meaningful gain (typically only for payloads > 2 KB).The practical guidance: do not add compression to signed JWTs (JWS). If you are considering it, your JWT is already too large and the design should be fixed by pruning claims. JWE compression (zip: "DEF") is appropriate when you must transmit a large encrypted payload — for example, a fully self-contained encrypted access token in a zero-trust architecture. For most applications, the claim-pruning techniques in the previous section are the correct approach.
Definitions
- Base64URL encoding
- A variant of Base64 that uses
-and_instead of+and/, and omits padding characters (=). Produces URL-safe strings. All three JWT segments (header, payload, signature) are Base64URL-encoded. The encoding expands input by 33%: 3 input bytes become 4 output characters. Explained in detail in Base64URL encoding explained. - Claim
- A key-value pair in the JWT payload JSON object. RFC 7519 defines registered claims with short names (
sub,iss,aud,exp,nbf,iat,jti). Applications may add custom (private) claims. Every claim adds bytes to the payload JSON, which is then Base64URL-encoded. See the JWT claims reference for the full registered claim set. - Opaque token
- A cryptographically random string (typically 32–64 bytes, Base64URL-encoded) with no embedded data. The server looks up session data by this token in a store (Redis, database). Opaque tokens are always tiny (43–86 characters), support instant revocation, and allow session data to be updated without reissuing the token. The trade-off is a server-side lookup on every request.
- Session store
- A server-side data store (typically Redis or PostgreSQL) that maps an opaque token or session ID to full session data (user ID, roles, preferences, etc.). Redis is the standard choice: O(1) GET operations, TTL-based auto-expiry, and sub-millisecond latency on co-located instances. A session store is the server-side counterpart to the client-side JWT payload.
- large_client_header_buffers
- An nginx configuration directive that controls the number and size of buffers used to read large client request headers. Default:
4 8k(four 8 KB buffers). A single header line (such asAuthorization: Bearer <token>) must fit within one buffer. If the header exceeds 8 KB, nginx returns HTTP 400. Set to4 16kto support JWTs up to ~16 KB, but consider reducing the JWT size instead. - Chunked cookie
- A workaround for the 4,096-byte cookie size limit where a large JWT is split across multiple cookies (e.g.,
token_0,token_1). Each chunk is within the 4 KB limit; the server reassembles them. This approach is fragile, adds complexity, and is a design anti-pattern. If you need chunked cookies, the token is too large and should be redesigned. - JWE compression
- DEFLATE compression of a JWE (JSON Web Encryption) payload before encryption, as defined by RFC 7516 via the
zip: "DEF"header parameter. Only applicable to encrypted tokens (JWE), not plain signed tokens (JWS/JWT). Provides meaningful size reduction only for payloads larger than 1–2 KB, and only when the payload has repetitive structure that DEFLATE can exploit.
Frequently asked questions
What is the maximum size of a JWT token?
There is no universal maximum — the limit depends on where the token is used. Browser cookies cap at 4,096 bytes (RFC 6265). nginx rejects header lines over 8 KB by default. AWS API Gateway hard-limits headers at 10 KB. URLs should stay under 2,000 characters for broad compatibility. A standard JWT with 5–6 registered claims is 200–400 bytes and fits everywhere. Problems arise when custom claims bloat the token beyond 1–2 KB. The practical rule: keep JWTs under 1 KB; if they exceed 2 KB, move bulk data to a server-side session store.
Why is my JWT being rejected by the server?
The most common cause is exceeding nginx's large_client_header_buffers limit (default: 8 KB per header line). nginx returns HTTP 400 or HTTP 431 before the request reaches your application code. Check the JWT byte size with Buffer.byteLength(token, 'utf8') in Node.js. If the token plus the "Authorization: Bearer " prefix (7 characters) exceeds 8,000 bytes, either increase the nginx buffer limit (large_client_header_buffers 4 16k) as a temporary fix, or reduce the token size by pruning claims. Also check for Apache's LimitRequestFieldSize(default: 8,190 bytes) and AWS API Gateway's hard 10 KB per-header limit.
How do I reduce the size of a JWT?
Apply these techniques in order: (1) audit claims and remove any that downstream services do not actually use; (2) abbreviate long claim names (e.g., "dept" instead of "department"); (3) replace permission arrays with OAuth 2.0 scope strings ("scope": "orders:rw invoices:r"); (4) move roles and permissions to a database and include only the user ID in the token; (5) use opaque IDs instead of readable strings for org names, plan names, etc. Avoid DEFLATE compression on unencrypted JWTs — the Base64URL re-encoding largely negates the gain for small payloads.
What is the cookie size limit for JWT?
RFC 6265 requires browsers to support cookies with at least 4,096 bytes in the name-plus-value combination. Chrome, Firefox, and Safari enforce this as a hard limit on the cookie value itself. After subtracting cookie name overhead (~10–20 bytes for the name and equals sign), the usable JWT space in a cookie is approximately 4,075 bytes. A standard 6-claim JWT (~300 bytes) fits comfortably. JWTs start hitting this limit when custom claims (roles, permissions, profile data) push the payload past ~3,000 bytes of raw JSON, which Base64URL-encodes to ~4,000 characters.
Can I store a large JWT in localStorage instead of a cookie?
localStorage supports up to 5 MB per origin, so there is no size problem — but storing JWTs in localStorage is a security anti-pattern regardless of token size. localStorage is readable by any JavaScript on the page; a single XSS vulnerability exfiltrates the token instantly. HttpOnly cookies cannot be read by JavaScript at all. If your JWT is too large for a cookie, the correct fix is to reduce the token size or switch to an opaque token — not to change storage location. A 43-character opaque session token in an HttpOnly cookie with full session data in Redis gives you small tokens, strong security, and no size limit problems.
How do I calculate the size of my JWT before sending it?
A JWT uses only ASCII characters, so its byte length equals its character count. In Node.js: Buffer.byteLength(token, 'utf8') or token.length. In Python: len(token.encode('utf-8')). In the browser: new TextEncoder().encode(token).length. You can also use Jsonic's JWT Decoder to paste your token and see its exact size and per-section breakdown instantly. The fixed-size parts: HS256 signature is always 43 Base64URL characters; RS256 signature is always 342 characters; the standard HS256 header is always 36 characters. Variable size is entirely in the payload — a 100-byte JSON payload becomes ~136 Base64URL characters.
What happens when a JWT exceeds the HTTP header size limit?
The server rejects the request with HTTP 400 Bad Request or HTTP 431 Request Header Fields Too Large — before your application code runs. This is difficult to diagnose because the error message often does not specify which header exceeded the limit. nginx logs it as a client error; Apache may log "Request header too long." The client sees only the HTTP status code. To diagnose: log the full Authorization header length on the client side before sending. To fix: reduce the JWT size (preferred) or increase the server buffer (large_client_header_buffers 4 16k in nginx, LimitRequestFieldSize 16384in Apache). AWS API Gateway's 10 KB limit is a hard cap that cannot be increased — token reduction is the only option there.
When should I use opaque tokens instead of JWTs?
Use opaque tokens when: your JWT consistently exceeds 1 KB after claim pruning; you need immediate revocation without maintaining a blocklist; you need to update session data (e.g., plan upgrade) without reissuing a token; or you are hitting a hard infrastructure limit such as AWS API Gateway's 10 KB header cap. The trade-off is a Redis lookup (0.5–20 ms depending on topology) on every authenticated request. Use JWTs when stateless verification is critical — edge functions, microservices with no shared Redis, or globally distributed systems. A 43-character opaque token in an HttpOnly cookie backed by Redis eliminates all JWT size limit problems and provides superior revocation semantics.
Further reading and primary sources
- RFC 6265 — HTTP State Management Mechanism (Cookies) — The official RFC defining the 4,096-byte cookie size requirement
- nginx large_client_header_buffers documentation — nginx configuration reference for request header buffer limits
- RFC 7519 — JSON Web Token (JWT) — The JWT specification, including the Base64URL encoding definition
Inspect and measure your JWT
Use Jsonic's JWT Decoder to paste any token and see the header, payload, claims, and exact byte size — all client-side, nothing sent to a server.
Open JWT Decoder →