Fixing Heavy JWTs: Bitmasking for Lean & Scalable Permissions
Posted on July 27, 2025 • 7 minutes • 1361 words
Ever hit a wall with an error message that just screams, “Dude, you’re sending too much data!” Or maybe your shiny, new Single Page Application (SPA) suddenly starts acting weird, requests dropping like flies? Yeah, I’ve been there. For us, the culprit turned out to be our good old friend, the JSON Web Token (JWT), specifically when it got absolutely packed with permissions and claims. What started as a neat, efficient way to handle authorization quickly spiraled into a monster that was literally too big for its britches.
The Problem: When JWTs Get Greedy
Here’s what I’ve been through: I started with basic JWT claims, just iss
, exp
, iat
, userid
, and email
. It typically looks something like this (decoded for clarity):
// Example of a basic, decoded JWT payload
{
"iss": "your-auth-server.com",
"exp": 1735689600,
"iat": 1704067200,
"userid": "user123",
"email": "user@example.com"
}
Then came the permissions. Initially, a few dozen strings for services like USER_CREATE
, PRODUCT_VIEW
, etc., were fine. But then permissions got really granular. Think INVENTORY_ITEM_CREATE_BATCH
, SALES_REPORT_VIEW_BY_REGION
. Suddenly, that permissions
array wasn’t just a few dozen strings; it was hundreds, sometimes thousands.
Here’s a glimpse of what an overstuffed JWT payload looked like:
// Example of an overstuffed JWT payload
{
"iss": "your-auth-server.com",
"exp": 1735689600,
"iat": 1704067200,
"userid": "user_with_all_the_permissions",
"email": "user@example.com",
"permissions": [
"USER_CREATE", "USER_READ", "PRODUCT_VIEW", "ORDER_CREATE",
"INVENTORY_ADD", "SALES_VIEW_ALL", "REPORT_GENERATE_FINANCIAL",
// ... many, many more. This list alone could easily push the JWT well past 4KB when encoded.
]
}
Let’s Talk Numbers: Calculating JWT Size
So, how does a few dozen permissions turn into a monster token? It’s simple math, really, plus a bit of JSON and Base64 encoding overhead. The size issue almost always comes down to the Payload.
Let’s break down the approximate size contributors:
Component | Estimated JSON Characters/Bytes | Notes |
---|---|---|
JWT Header (JSON String) | ~40 characters | Example: {"alg":"HS256","typ":"JWT"} |
Fixed Payload Claims (JSON String) | ~200 characters | iss , exp , iat , userid , email , plus the permissions array structure {"permissions":[]} |
Average Permission String Length | 10 characters | E.g., USER_CREATE |
Overhead per Permission in Array | ~3 characters | For quotes ("" ) and comma (, ) in JSON, i.e., "PERMISSION", |
Effective Length per Permission in JSON Payload | 13 characters | (10 characters for string + 3 characters for overhead) |
Base64Url Encoding Overhead | ~33% | Converts 3 bytes of raw data into 4 Base64 characters |
JWT Signature (Encoded) | ~43 bytes | Fixed size for HS256 algorithm |
To hit those common HTTP header limits, like 4KB or 8KB, it doesn’t take as many permissions as you’d think. Based on these numbers, having around 200-220 permissions, each just 10 characters long, can easily push your encoded JWT to over 4.5 KB. This immediately puts you at risk of those dreaded “Request Header Fields Too Large” errors from your web server or proxy.
Solution: Bitmasking to the Rescue!
Okay, so we’ve got this heavy JWT problem. What’s the fix? Well, I gotta tell you, I was doing some digging, and I stumbled across how Linux handles permissions using bitmasking—it’s both efficient and incredibly scalable. Lightbulb moment! I figured, “Hey, if it works for Linux, why can’t we use this magic for our JWT permissions, especially now that they’re multiplying like rabbits?“0
🔐 From Linux Permissions to Our Lean, Mean JWT System
In Linux, file permissions are a classic example of bitmasking in action. Each permission—read (r), write (w), and execute (x)—is represented by a specific bit. Combine them, and boom you’ve got a permission mask.
Role | Binary | Octal | Symbolic |
---|---|---|---|
Owner | 111 | 7 | rwx |
Group | 101 | 5 | r-x |
Others | 101 | 5 | r-x |
Inspired by this efficiency, I cooked up a JWT-based permission model using bitmasking in PHP. Instead of slapping in a bunch of separate flags, we assign each permission a unique bit, like 1 << n
.
Here’s how we set up our permission flags:
const PERMISSION_FLAGS = [
"BLOG_CREATE" => 1 << 0, // 1
"BLOG_READ" => 1 << 1, // 2
"BLOG_UPDATE" => 1 << 2, // 4
// ...
"PERMISSION_64" => 1 << 63 // A whopping 2^63!
];
🔍 Why this works so darn well:
- Scalable: You can handle up to 64 distinct permissions in a single 64-bit integer. And here’s the cool part: if you need even more, you can simply use multiple integers to represent hundreds or even thousands of permissions, chaining them together. A common strategy is to have separate masks for different service areas (e.g.,
blogPermissionsMask
,userPermissionsMask
,orderPermissionsMask
). Want to learn how to manage these advanced multi-mask architectures for truly massive systems? Check out the continuation post here: “Scaling Bitmasking: Mastering Permissions Beyond 64 Bits with Multiple Masks”. But often, 64 is plenty for many granular systems. - Compact: We only need one integer (or a few, if you’re really going wild) to store and pass around. This is where we solve our “token too fat” problem!
- Fast: Permission checks are simple bitwise operations like
$mask & PERMISSION
, incredibly efficient for both backend and frontend.
✅ Example: Checking and Calculating Permissions It’s Easy Peasy!
Want to see how concise checking and calculating permissions becomes? Here’s the deal for the backend (PHP example):
<?php
const PERMISSION_FLAGS = [
"BLOG_CREATE" => 1 << 0, "BLOG_READ" => 1 << 1, "BLOG_UPDATE" => 1 << 2,
"BLOG_DELETE" => 1 << 3, "COMMENT_CREATE" => 1 << 4, "DASHBOARD_ACCESS" => 1 << 5,
"PERMISSION_33" => 1 << 32
];
function calculatePermissionMask(array $permissionNames): int {
$mask = 0;
foreach ($permissionNames as $name) {
if (isset(PERMISSION_FLAGS[$name])) { $mask |= PERMISSION_FLAGS[$name]; }
}
return $mask;
}
function hasPermission(int $userPermissionMask, string $permissionToCheck): bool {
if (!isset(PERMISSION_FLAGS[$permissionToCheck])) { return false; }
return ($userPermissionMask & PERMISSION_FLAGS[$permissionToCheck]) !== 0;
}
$userPermissions = calculatePermissionMask(["BLOG_READ", "COMMENT_CREATE", "DASHBOARD_ACCESS", "PERMISSION_33"]);
echo "User mask (decimal): " . $userPermissions . "\n";
echo "Can read blog? " . (hasPermission($userPermissions, "BLOG_READ") ? "Yes" : "No") . "\n";
echo "Can delete blog? " . (hasPermission($userPermissions, "BLOG_DELETE") ? "Yes" : "No") . "\n";
?>
And for the Frontend (Vanilla JavaScript)
Since our frontend uses these JWTs to decide what to show and hide, we need a similar setup there. Good news: bitwise operations are universal!
const PERMISSION_FLAGS_JS = {
"BLOG_CREATE": 1 << 0, "BLOG_READ": 1 << 1, "BLOG_UPDATE": 1 << 2,
"BLOG_DELETE": 1 << 3, "COMMENT_CREATE": 1 << 4, "DASHBOARD_ACCESS": 1 << 5,
"PERMISSION_33": 1 << 32, "PERMISSION_64": 1 << 63
};
// Assume 'permissionMask' comes from your decoded JWT payload
const decodedJwtPayload = { "permissionMask": 8589934642 };
const userPermissionMaskJS = decodedJwtPayload.permissionMask;
function hasPermissionJS(permissionToCheck) {
if (!(permissionToCheck in PERMISSION_FLAGS_JS)) { return false; }
return (userPermissionMaskJS & PERMISSION_FLAGS_JS[permissionToCheck]) !== 0;
}
console.log("Can view blog module? ", hasPermissionJS("BLOG_READ") ? "Yes!" : "No.");
// Dynamically showing/hiding a button
const createBlogButton = document.getElementById('createBlogButton');
if (createBlogButton) {
createBlogButton.style.display = hasPermissionJS("BLOG_CREATE") ? 'block' : 'none';
}
console.log("Can access settings? ", hasPermissionJS("SETTINGS_SYSTEM_CONFIG") ? "Yep!" : "Nah.");
This logic echoes Linux’s philosophy: use bits for what they’re best at—fast and compact access control.
🚀 Final Thought: Bitmasking - Not Just for Old-School Geeks!
Bitmasking isn’t some dusty relic from system programming textbooks it’s a timeless solution, pure and simple. It lets you express a ton of complexity with surprising simplicity.
And guess what? It directly tackles our heavy JWT problem head-on. Remember that calculation where just 200-220 granular permissions could swell your JWT up to an estimated 4.5KB or more, risking those annoying server errors?
With bitmasking, all those hundreds of permissions collapse into a highly compact numerical representation (often just one number, or a few if you’re really piling on the permissions). The difference is night and day. Take a look at the drastic improvement in size:
Feature | Before (String Array for 212 Permissions) | After (Bitmask for 212+ Permissions) |
---|---|---|
Permission Representation | An array of strings: ["USER_CREATE", "USER_READ", ...] | A compact numerical value (or values): e.g., 9223372036854775807 |
Payload Content | Thousands of characters for permissions array content | Around 20-80 characters (for 1-4 integers) for numerical mask(s) |
Approximate Encoded JWT Size | ~4.5 KB (4500 bytes) | ~0.33 KB - ~0.6 KB (330-600 bytes) |
HTTP Header Compatibility | Often exceeds common 4KB/8KB limits, causing errors | Easily stays well under 1KB, no issues |
Your JWT goes from being a heavyweight contender to a featherweight champion! It’s elegant, efficient, and as we’ve literally just seen, a fantastic way to keep your JWTs lean, mean, and actually working instead of throwing TooLargeRequest
tantrums. Give it a try!