July 27, 2025

Fixing Heavy JWTs: Bitmasking for Lean & Scalable Permissions

Posted on July 27, 2025  •  12 minutes  • 2449 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. For the uninitiated, a JWT is essentially a compact, URL-safe means of representing claims to be transferred between two parties. It typically looks something like this (decoded for clarity):

// Example of a basic, decoded JWT payload
{
  "iss": "your-auth-server.com",
  "exp": 1735689600, // Expiration time (e.g., Dec 31, 2024 00:00:00 GMT)
  "iat": 1704067200, // Issued at time (e.g., Jan 1, 2024 00:00:00 GMT)
  "userid": "user123",
  "email": "user@example.com"
}

Then came the permissions. Initially, permissions were straightforward. The number of permission strings depended on how many services the client opted for. For example, if a client purchased product, user, and order modules, we’d have a few permission strings based on these services, like USER_CREATE, USER_READ, USER_UPDATE, USER_DELETE, and so on for other services. This meant our JWT payload would start to grow, potentially looking something like this:

// Example of a JWT payload with basic permissions
{
  "iss": "your-auth-server.com",
  "exp": 1735689600,
  "iat": 1704067200,
  "userid": "user123",
  "email": "user@example.com",
  "permissions": [
    "USER_CREATE",
    "USER_READ",
    "USER_UPDATE",
    "USER_DELETE",
    "PRODUCT_VIEW",
    "ORDER_CREATE"
  ]
}

We were generating good JWTs.

This was all going well until we got more services and permissions became more granular. Services could increase to include inventory, sales, reports, settings, and payments. And not just broad permissions, but really specific ones. Think INVENTORY_ITEM_CREATE_BATCH, SALES_REPORT_VIEW_BY_REGION, SETTINGS_AUDIT_LOGS_ACCESS. Suddenly, that permissions array wasn’t just a few dozen strings; it was hundreds, sometimes thousands.

Here’s what a JWT payload can look like when it’s stuffed beyond recognition:

// Example of an overstuffed JWT payload (over 4KB in JSON string length)
{
  "iss": "your-auth-server.com",
  "exp": 1735689600,
  "iat": 1704067200,
  "userid": "very_important_user_with_all_the_permissions_and_a_long_id",
  "email": "very.important.user.with.many.roles@example.com",
  "permissions": [
    "USER_CREATE", "USER_READ", "USER_UPDATE", "USER_DELETE",
    "PRODUCT_CREATE", "PRODUCT_READ", "PRODUCT_UPDATE", "PRODUCT_DELETE",
    "ORDER_CREATE", "ORDER_READ", "ORDER_UPDATE", "ORDER_DELETE",
    "INVENTORY_ADD", "INVENTORY_REMOVE", "INVENTORY_ADJUST", "INVENTORY_VIEW",
    "SALES_VIEW_OWN", "SALES_VIEW_ALL", "SALES_ANALYTICS_ACCESS", "SALES_EXPORT",
    "REPORT_GENERATE_FINANCIAL", "REPORT_GENERATE_OPERATIONAL", "REPORT_VIEW_HISTORY",
    "SETTINGS_USER_MANAGEMENT", "SETTINGS_ROLE_MANAGEMENT", "SETTINGS_SYSTEM_CONFIG",
    "PAYMENT_PROCESS", "PAYMENT_REFUND", "PAYMENT_VOID", "PAYMENT_VIEW_TRANSACTIONS",
    // ... imagine hundreds or thousands more granular permissions like these:
    "INVENTORY_WAREHOUSE_A_ADD", "INVENTORY_WAREHOUSE_B_ADD", "INVENTORY_ITEM_TYPE_1_VIEW",
    "SALES_DASHBOARD_REGION_NORTH_VIEW", "SALES_DASHBOARD_REGION_SOUTH_VIEW",
    "ORDER_APPROVE_HIGH_VALUE", "ORDER_REJECT_LOW_STOCK",
    "PRODUCT_DISCOUNT_APPLY_GLOBAL", "PRODUCT_DISCOUNT_APPLY_SPECIFIC_ITEM",
    "USER_AUDIT_LOG_READ", "USER_PASSWORD_RESET_FORCE",
    // ... 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.

A JWT is typically composed of three parts: Header, Payload, and Signature, each Base64Url encoded and separated by dots. The size issue almost always comes down to the Payload.

Let’s break down the approximate size contributors:

ComponentEstimated JSON Characters/BytesNotes
JWT Header (JSON String)~40 charactersExample: {"alg":"HS256","typ":"JWT"}
Fixed Payload Claims (JSON String)~200 charactersiss, exp, iat, userid, email, plus the permissions array structure {"permissions":[]}
Average Permission String Length10 charactersE.g., USER_CREATE
Overhead per Permission in Array~3 charactersFor quotes ("") and comma (,) in JSON, i.e., "PERMISSION",
Effective Length per Permission in JSON Payload13 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 bytesFixed size for HS256 algorithm

Now, let’s target hitting one of those common limits. We’ll aim for an encoded JWT size of 4500 bytes (4.5 KB), which is clearly above the safe 4KB recommendation and approaching the 8KB common limit.

Here’s how we figure out how many permissions it takes:

Step 1: Estimate the required Raw JSON Payload Size

We know the final encoded JWT is roughly 1.5 times the raw JSON string length of its header and payload. So, to hit 4500 bytes:

CalculationResultExplanation
Target Encoded JWT Size4500 bytesOur target to demonstrate “too large”
Divided by Encoding Factor (1.5)3000 charactersApproximate raw JSON size needed for Header + Payload
Subtract Header JSON size3000 - 40 = 2960 charactersRemaining characters needed for the Payload JSON string

Step 2: Calculate how many permissions fit in the remaining Payload space

This remaining space (2960 characters) needs to accommodate our fixed payload claims and all the permission strings.

CalculationResultExplanation
Payload JSON characters available for claims2960 charactersFrom Step 1
Subtract Fixed Payload Claims (JSON)2960 - 200 = 2760 charactersCharacters remaining specifically for the permissions array content
Divide by Effective Length per Permission (13 chars)$2760 / 13 \approx 212$ permissionsThe number of 10-character permissions we can cram in

The Bottom Line:

If you have around 200-220 permissions, each a modest 10 characters long, your JWT could easily hit or even exceed the 4KB (4096 bytes) safe limit and rapidly approach the 8KB (8192 bytes) default HTTP header limit. That’s not even counting any other custom claims you might add!


Spotting the Problem: Common Error Messages

So, you’re hitting these limits. What does it actually look like when your JWTs get too heavy? It usually results in some pretty gnarly error messages from your server or proxy, often before your request even hits your application code. If you see any of these, your oversized JWT is probably the culprit:

If you’re seeing any of these, especially if you’re stuffing a lot of data into your JWTs, it’s time to consider a more compact approach.


Solution: Bitmasking to the Rescue!

Okay, so we’ve got this chunky 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?”


🔐 From Linux Permissions to Our Lean, Mean JWT System

So, imagine this: Linux file permissions. Total classic example of bitmasking doing its thing. You know, read (r), write (w), and execute (x)? Each one of those gets its own little spot, its own specific bit. You combine ’em, and boom – you’ve got a permission mask. Check this out:

RoleBinaryOctalSymbolic
Owner1117rwx
Group1015r-x
Others1015r-x

So, if you see a permission like 755, it’s basically shorthand for:

Owner: rwx (7)
Group: r-x (5)
Others: r-x (5)
=> Or, if you're into binary: 111 101 101

See? Each tiny bit screams out a specific capability. Mash ’em together, and you get these super powerful, super compact ways to say who can do what. Pretty neat, right?

That efficiency totally lit a fire under me. I thought, “Let’s apply this brilliance!” So, I cooked up a JWT-based permission model using bitmasking in PHP. Instead of slapping in a bunch of separate true/false flags for every single permission (which, as we saw, makes JWTs huge), we just assign each permission a unique bit. It’s like 1 << n, where n is its position, just like those Linux permissions.

Here’s a little peek at how we set up our permission flags:

const PERMISSION_FLAGS = [
    "BLOG_CREATE" => 1 << 0,  // That's 2^0 = 1. Simple, right?
    "BLOG_READ"   => 1 << 1,  // And 2^1 = 2
    "BLOG_UPDATE" => 1 << 2,  // You guessed it: 2^2 = 4
    // ... and so on. We can keep going...
    "PERMISSION_64" => 1 << 63 // That's a whopping 2^63! Seriously, this number is huge.
];

🔍 Why this works so darn well:


✅ 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

// Our list of permissions and their corresponding bit flags
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 // Just an example for a higher bit
    // ... more permissions would go here
];

// Imagine we have a function to mash together all a user's permissions
function calculatePermissionMask(array $permissionNames): int {
    $mask = 0; // Start with nothing
    foreach ($permissionNames as $name) {
        if (isset(PERMISSION_FLAGS[$name])) {
            $mask |= PERMISSION_FLAGS[$name]; // OR the bits together. Magic!
        }
    }
    return $mask;
}

// And another function to see if they have a specific permission
function hasPermission(int $userPermissionMask, string $permissionToCheck): bool {
    if (!isset(PERMISSION_FLAGS[$permissionToCheck])) {
        return false; // Oops, permission not even defined!
    }
    // Is the permission's bit set in the user's mask?
    return ($userPermissionMask & PERMISSION_FLAGS[$permissionToCheck]) !== 0;
}

// --- Let's see it in action! ---
$userPermissions = calculatePermissionMask([
    "BLOG_READ", "COMMENT_CREATE", "DASHBOARD_ACCESS", "PERMISSION_33"
]);

echo "Our user's permission mask looks like this (in decimal): " . $userPermissions . "\n";
// For the example flags, this would output 8589934592 + 32 + 2 + 16 = 8589934642.
// The important part is it's a *single* integer!

echo "Can our user read the blog? " . (hasPermission($userPermissions, "BLOG_READ") ? "Absolutely!" : "Nope.") . "\n";      // True, because they have it!
echo "Can our user delete the blog? " . (hasPermission($userPermissions, "BLOG_DELETE") ? "Yep!" : "Nah.") . "\n";    // False. Not in their mask.

?>

And for the Frontend (Vanilla JavaScript)

Since our frontend is also in on the action, using these JWTs to decide what to show and hide, we need a similar setup there. Good news: bitwise operations are universal!

First, you’d probably have your PERMISSION_FLAGS defined somewhere accessible, maybe loaded from a config or part of your build process.

// This would typically come from your backend or a shared config
// Make sure these match the backend's flags!
const PERMISSION_FLAGS_JS = {
    "BLOG_CREATE": 1 << 0,  // 1
    "BLOG_READ":   1 << 1,  // 2
    "BLOG_UPDATE": 1 << 2,  // 4
    "BLOG_DELETE": 1 << 3,  // 8
    "COMMENT_CREATE": 1 << 4, // 16
    "DASHBOARD_ACCESS": 1 << 5, // 32
    // ...
    "PERMISSION_33": 1 << 32, // For demonstration of higher bits
    "PERMISSION_64": 1 << 63 // Yeah, JS handles these big numbers fine as BigInt if needed,
                             // but for 63 bits, standard numbers usually work up to 2^53.
                             // For larger, you might use BigInt(1) << BigInt(63).
};

// Imagine our JWT payload is decoded and looks like this:
const decodedJwtPayload = {
  // ... other claims ...
  "permissionMask": 8589934642 // This is the single integer from the backend, matching the example!
};

// Let's grab that mask from our (dummy) JWT payload
const userPermissionMaskJS = decodedJwtPayload.permissionMask; // Or wherever you extract it

// Function to check if the user has a specific permission
function hasPermissionJS(permissionToCheck) {
    if (!(permissionToCheck in PERMISSION_FLAGS_JS)) {
        return false; // Permission not defined on frontend
    }
    // Is the permission's bit set in the user's mask?
    // The bitwise AND (&) operation is your friend here!
    return (userPermissionMaskJS & PERMISSION_FLAGS_JS[permissionToCheck]) !== 0;
}

// --- How the Frontend uses it for UI! ---
console.log("Can user view blog module? ", hasPermissionJS("BLOG_READ") ? "Yes!" : "No.");
// Example: Dynamically showing/hiding a button
const createBlogButton = document.getElementById('createBlogButton');
if (createBlogButton) {
    if (hasPermissionJS("BLOG_CREATE")) {
        createBlogButton.style.display = 'block'; // Show the button
    } else {
        createBlogButton.style.display = 'none'; // Hide it!
    }
}

console.log("Can user access settings? ", hasPermissionJS("SETTINGS_SYSTEM_CONFIG") ? "Yep!" : "Nah."); // Assuming this flag exists

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. Whether you’re wrangling file access in Linux or trying to keep user permissions in check for your shiny, modern web apps, it’s a seriously powerful tool. 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 ‘TooLongFrameException’ errors?

With bitmasking, all those hundreds of permissions collapse into a single integer. The difference is night and day. Take a look at the drastic improvement in size:

FeatureBefore (String Array for 212 Permissions)After (Bitmask for 212+ Permissions)
Permission RepresentationAn array of strings: ["USER_CREATE", "USER_READ", ..., "PERMISSION_212"]A single integer: "permissionMask":9223372036854775807
Payload ContentThousands of characters for permissions array contentAround 20 characters for a 64-bit integer string representation
Approximate Encoded JWT Size~4.5 KB (4500 bytes)~0.33 KB (330 bytes)
HTTP Header CompatibilityOften exceeds common 4KB/8KB limits, causing errorsEasily stays well under 1KB (or even a few hundred bytes), 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 'TooLongFrameException' tantrums. Give it a try!

Follow me

...