In Part 1, I showed you how bitmasks work - the maths, the operators, the theory. Now let's actually use them.
If you're building an app with user permissions (and you probably are), you're likely doing one of two things:
- Querying your database on every request:
SELECT permissions FROM users WHERE id = ? - Storing permissions in your JWT and hoping they don't go stale
Both have problems. Let me show you a third way. 🛠️
The Problem with Database Queries
Here's what most auth middleware looks like:
// Traditional permission check
async function requirePermission(permissionName) {
return async (req, res, next) => {
const userId = req.user.id;
// Hit the database
const userPerms = await db.query(
'SELECT permission FROM user_permissions WHERE user_id = ? AND permission = ?',
[userId, permissionName]
);
if (userPerms.length === 0) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Every protected route does this
app.delete('/posts/:id', requirePermission('delete_posts'), async (req, res) => {
// Delete the post
});
What's wrong with this?
Every single request hits your database. If your API handles 10,000 requests per second, that's 10,000 database queries just for permission checks. Your database becomes a bottleneck.
You could cache the results, but then you've got cache invalidation problems. You could store permissions in Redis, but now you're adding network latency and another service to manage.
The Problem with JWTs
Alternative approach - store permissions in the JWT:
// Sign JWT with permissions
const token = jwt.sign({
userId: user.id,
permissions: ['create_posts', 'edit_posts', 'delete_posts']
}, SECRET);
// Check permissions from token
function requirePermission(permissionName) {
return (req, res, next) => {
if (!req.user.permissions.includes(permissionName)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
What's wrong with this?
JWTs can't be invalidated. If you revoke a user's permissions, they still have a valid token until it expires. You're trading database queries for stale data.
Also, .includes() on an array is O(n) - it checks each element until it finds a match. With 10+ permissions, that's 10+ comparisons per check.
The Bitmask Approach
Instead of storing permission strings or querying a database, store permissions as a single number.
Define your permissions:
// permissions.js
const PERMISSIONS = {
CREATE_POSTS: 1 << 0, // 1
EDIT_OWN_POSTS: 1 << 1, // 2
EDIT_ALL_POSTS: 1 << 2, // 4
DELETE_OWN_POSTS: 1 << 3, // 8
DELETE_ALL_POSTS: 1 << 4, // 16
MANAGE_USERS: 1 << 5, // 32
VIEW_ANALYTICS: 1 << 6, // 64
EXPORT_DATA: 1 << 7, // 128
};
// Helper to check permissions
function hasPermission(userPerms, requiredPerm) {
return (userPerms & requiredPerm) !== 0;
}
// Helper to add permissions
function addPermission(userPerms, newPerm) {
return userPerms | newPerm;
}
// Helper to remove permissions
function removePermission(userPerms, perm) {
return userPerms & ~perm;
}
module.exports = { PERMISSIONS, hasPermission, addPermission, removePermission };
Why 1 << 0, 1 << 1, etc?
The << operator is a bit shift. 1 << 0 means "shift the number 1 left by 0 positions" which equals 1. 1 << 1 means "shift 1 left by 1 position" which equals 2. 1 << 2 equals 4, and so on.
It's just a cleaner way to write powers of 2. You could write 1, 2, 4, 8, 16... but 1 << 0, 1 << 1, 1 << 2... makes the pattern obvious and prevents typos.
Storing Permissions in Your Database
Instead of a junction table with rows for each permission:
-- Old way: Many rows per user
CREATE TABLE user_permissions (
user_id INT,
permission VARCHAR(50)
);
-- User with 5 permissions = 5 rows
-- 10M users with 5 permissions = 50M rows
Store one integer per user:
-- New way: One column
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR(255),
password_hash VARCHAR(255),
permissions INT DEFAULT 0
);
-- User with 5 permissions = still 1 row
-- 10M users = 10M rows (not 50M)
Creating a user with permissions:
const { PERMISSIONS, addPermission } = require('./permissions');
// Basic user: can create and edit own posts
let userPerms = 0;
userPerms = addPermission(userPerms, PERMISSIONS.CREATE_POSTS);
userPerms = addPermission(userPerms, PERMISSIONS.EDIT_OWN_POSTS);
// Or do it in one line
const userPerms = PERMISSIONS.CREATE_POSTS | PERMISSIONS.EDIT_OWN_POSTS;
await db.query(
'INSERT INTO users (email, password_hash, permissions) VALUES (?, ?, ?)',
[email, passwordHash, userPerms]
);
Updating permissions:
// Promote user to moderator
const currentPerms = user.permissions;
const newPerms = currentPerms | PERMISSIONS.DELETE_ALL_POSTS | PERMISSIONS.EDIT_ALL_POSTS;
await db.query('UPDATE users SET permissions = ? WHERE id = ?', [newPerms, userId]);
Middleware Implementation
Here's the auth middleware:
// middleware/auth.js
const { PERMISSIONS, hasPermission } = require('../permissions');
/**
* Require specific permission
* @param {number} requiredPermission - Permission bitmask
*/
function requirePermission(requiredPermission) {
return (req, res, next) => {
// Assume req.user was set by your auth middleware
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!hasPermission(req.user.permissions, requiredPermission)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
/**
* Require ALL specified permissions
* @param {...number} requiredPermissions - Multiple permission bitmasks
*/
function requireAllPermissions(...requiredPermissions) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Combine all required permissions with OR
const combined = requiredPermissions.reduce((acc, perm) => acc | perm, 0);
// Check if user has ALL of them
if ((req.user.permissions & combined) !== combined) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
/**
* Require ANY of the specified permissions
* @param {...number} requiredPermissions - Multiple permission bitmasks
*/
function requireAnyPermission(...requiredPermissions) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const hasAny = requiredPermissions.some(perm =>
hasPermission(req.user.permissions, perm)
);
if (!hasAny) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { requirePermission, requireAllPermissions, requireAnyPermission };
Using it in routes:
const { PERMISSIONS } = require('./permissions');
const { requirePermission, requireAllPermissions } = require('./middleware/auth');
// Simple permission check
app.delete('/posts/:id',
requirePermission(PERMISSIONS.DELETE_ALL_POSTS),
async (req, res) => {
// Delete the post
}
);
// Require multiple permissions
app.post('/analytics/export',
requireAllPermissions(PERMISSIONS.VIEW_ANALYTICS, PERMISSIONS.EXPORT_DATA),
async (req, res) => {
// Export analytics data
}
);
Performance: Real Numbers
Right, so I keep saying "bitmasks are faster." Let me prove it.
I ran benchmarks comparing three approaches with 1 million operations each:
-
Array .includes():
permissions.includes('delete_posts') -
Bitmask check:
permissions & DELETE_POSTS
Results:
| Approach | Ops/Second | Speedup |
|---|---|---|
| Array .includes() | 2,093,325 | baseline |
| Bitmask check | 3,012,325 | 1.5x faster |
The bitmask approach is 50% faster on average. In the best case, it was 2.28x faster.
Why? Because & is a single CPU instruction. Array .includes() has to loop through each element, comparing strings until it finds a match (or doesn't).
Memory: The Real Win
Here's where bitmasks really shine. I tested storing 100,000 user objects with permissions:
Memory usage:
| Approach | Total Memory | Per Object | Savings |
|---|---|---|---|
| Multiple booleans | 12.05 MB | 126.34 bytes | baseline |
| Array of strings | 11.58 MB | 121.43 bytes | 4% |
| Single bitmask | 6.47 MB | 67.79 bytes | 46.3% |
The bitmask approach uses 46.3% less memory than multiple boolean properties. That's 58.55 bytes saved per object.
Scale this:
- 1 million users: ~55.8 MB saved
- 10 million users: ~558 MB saved
- 100 million users: ~5.58 GB saved
Your database thanks you. Your cloud bill thanks you. And cloud providers now have us both on their hit list.
Full benchmark results and reproducible tests
Combining with Your Auth Strategy
You're probably thinking: "But I still need to fetch the user from somewhere."
You do. Bitmasks don't eliminate authentication - they just make authorization faster. Authentication is not authorization.
Combining with Your Auth Strategy
A quick note on storing permissions in JWTs: This is somewhat controversial in the auth community. Some argue JWTs should only carry identity, not authorization data. Others (including me) find it acceptable for high-level roles stored as bitmasks in short-lived tokens.
The compromise:
- ✅ Store user roles/permissions as a bitmask (keeps token small)
- ✅ Keep access tokens very short-lived (5-15 min max)
- ✅ Use refresh tokens to get updated permissions
- ❌ Don't store fine-grained resource permissions (which files can they access, etc.)
- ❌ Don't rely on this for highly dynamic permissions
For complex authorization (resource-level permissions, dynamic policies), look into policy engines like Ory or Zanzibar-style systems.
Right, so with that context...
Here's how I use them with the hybrid auth approach from my JWT vs Sessions article:
// 1. Short-lived access token (15 minutes)
const accessToken = jwt.sign({
userId: user.id,
permissions: user.permissions // <- Single integer
}, ACCESS_SECRET, { expiresIn: '15m' });
// 2. Long-lived refresh token (30 days) in database
const refreshToken = generateSecureToken();
await db.query(
'INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (?, ?, ?)',
[refreshToken, user.id, Date.now() + 30 * 24 * 60 * 60 * 1000]
);
Permission check middleware:
function requirePermission(requiredPerm) {
return async (req, res, next) => {
// Verify JWT (fast - no database)
const decoded = jwt.verify(req.headers.authorization, ACCESS_SECRET);
// Check permission (fast - bitwise operation)
if ((decoded.permissions & requiredPerm) === 0) {
return res.status(403).json({ error: 'Forbidden' });
}
req.user = decoded;
next();
};
}
When permissions change:
// Revoke permission
const newPerms = user.permissions & ~PERMISSIONS.DELETE_ALL_POSTS;
await db.query('UPDATE users SET permissions = ? WHERE id = ?', [newPerms, user.id]);
// Invalidate their refresh token (forces new access token on next refresh)
await db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [user.id]);
Next time they refresh, they get a new access token with updated permissions. Worst case: they have 15 minutes with old permissions. That's acceptable for most apps.
Why Bitmask in JWT?
Here's why storing permissions as a bitmask in your JWT makes sense:
Token size comparison:
// String array approach
{
"userId": 123,
"permissions": ["create_posts", "edit_own_posts", "edit_all_posts", "delete_own_posts"]
}
// ~180 bytes
// Bitmask approach
{
"userId": 123,
"permissions": 15
}
// ~50 bytes - 72% smaller
This matters because HTTP headers have an 8KB limit. Every request carries your JWT. Smaller tokens = less bandwidth, faster sign/verify operations.
Role-Based Permissions
You can define roles as combinations of permissions:
// roles.js
const { PERMISSIONS } = require('./permissions');
const ROLES = {
VIEWER: PERMISSIONS.CREATE_POSTS |
PERMISSIONS.EDIT_OWN_POSTS,
EDITOR: PERMISSIONS.CREATE_POSTS |
PERMISSIONS.EDIT_OWN_POSTS |
PERMISSIONS.EDIT_ALL_POSTS,
MODERATOR: PERMISSIONS.CREATE_POSTS |
PERMISSIONS.EDIT_OWN_POSTS |
PERMISSIONS.EDIT_ALL_POSTS |
PERMISSIONS.DELETE_OWN_POSTS |
PERMISSIONS.DELETE_ALL_POSTS,
ADMIN: PERMISSIONS.CREATE_POSTS |
PERMISSIONS.EDIT_OWN_POSTS |
PERMISSIONS.EDIT_ALL_POSTS |
PERMISSIONS.DELETE_OWN_POSTS |
PERMISSIONS.DELETE_ALL_POSTS |
PERMISSIONS.MANAGE_USERS |
PERMISSIONS.VIEW_ANALYTICS |
PERMISSIONS.EXPORT_DATA,
};
module.exports = { ROLES };
Assigning roles:
const { ROLES } = require('./roles');
// Make someone a moderator
await db.query('UPDATE users SET permissions = ? WHERE id = ?', [ROLES.MODERATOR, userId]);
You can still add individual permissions on top:
// Moderator + ability to export data
const customPerms = ROLES.MODERATOR | PERMISSIONS.EXPORT_DATA;
Or remove specific permissions:
// Admin without ability to manage users
const customPerms = ROLES.ADMIN & ~PERMISSIONS.MANAGE_USERS;
Security Considerations (The Important Bit)
Bitmasks are fast, but speed doesn't matter if your auth system is insecure. Here's what you need to know:
Permission Staleness Window
When you store permissions in a JWT, they become stale the moment you change them server-side.
// User has admin privileges
const token = jwt.sign({ permissions: ROLES.ADMIN }, SECRET, { expiresIn: '15m' });
// 5 minutes later, you revoke their admin access in the database
await db.query('UPDATE users SET permissions = ? WHERE id = ?', [ROLES.VIEWER, userId]);
// But their token still says they're admin for another 10 minutes
Mitigation: Keep access tokens short-lived (5-15 minutes). Use refresh tokens to get updated permissions.
Token Revocation is Impossible
You cannot revoke a JWT before it expires. This is a fundamental limitation of stateless tokens.
If a user's account is compromised, the attacker has access until the token expires. Your options:
- Short-lived tokens (5-15 min) - limits damage window
- Refresh token invalidation - revoke the refresh token, force new login on next refresh
- Token blacklist (defeats the "stateless" purpose, but sometimes necessary)
Bit Position Management
Once you assign a permission to a bit position, never reuse that position for a different permission.
// Version 1
const CAN_DELETE = 1 << 4; // Bit 4
// Version 2 (WRONG - reusing bit 4)
const CAN_EXPORT = 1 << 4; // Now bit 4 means something else
// Old tokens with bit 4 set now have wrong permission
Best practice: Document bit positions, version your permission schema, never reuse.
Principle of Least Privilege
Give users the minimum permissions they need. Don't create a "SUPER_ADMIN" role with all bits set unless absolutely necessary.
// Bad: Kitchen sink role
const POWER_USER = ROLES.ADMIN | PERMISSIONS.EXPORT_DATA | PERMISSIONS.DELETE_ALL | ...;
// Good: Specific role for specific job
const MODERATOR = PERMISSIONS.DELETE_OWN_POSTS | PERMISSIONS.EDIT_ALL_POSTS;
Audit Logging
Log permission checks, especially failures:
function requirePermission(requiredPerm) {
return (req, res, next) => {
if (!hasPermission(req.user.permissions, requiredPerm)) {
// Log the denial
logger.warn('Permission denied', {
userId: req.user.id,
required: debugPermissions(requiredPerm),
actual: debugPermissions(req.user.permissions),
endpoint: req.path
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
This helps you detect:
- Privilege escalation attempts
- Permission misconfigurations
- Unusual access patterns
Debugging Helper Functions
Remember how I said debugging is harder because you see numbers instead of permission names? Here's the fix:
// permissions.js (add this)
/**
* Convert permission bitmask to readable array
* @param {number} perms - Permission bitmask
* @returns {string[]} Array of permission names
*/
function debugPermissions(perms) {
const enabled = [];
for (const [name, value] of Object.entries(PERMISSIONS)) {
if (perms & value) {
enabled.push(name);
}
}
return enabled;
}
/**
* Convert permission bitmask to readable string
* @param {number} perms - Permission bitmask
* @returns {string} Comma-separated permission names
*/
function formatPermissions(perms) {
const enabled = debugPermissions(perms);
return enabled.length > 0 ? enabled.join(', ') : 'No permissions';
}
module.exports = {
PERMISSIONS,
hasPermission,
addPermission,
removePermission,
debugPermissions,
formatPermissions
};
Usage:
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
console.log('User permissions:', user.permissions);
// Output: 87
console.log('Permissions:', debugPermissions(user.permissions));
// Output: ['CREATE_POSTS', 'EDIT_OWN_POSTS', 'DELETE_OWN_POSTS', 'MANAGE_USERS']
console.log(formatPermissions(user.permissions));
// Output: CREATE_POSTS, EDIT_OWN_POSTS, DELETE_OWN_POSTS, MANAGE_USERS
This makes development and debugging much easier whilst keeping production code fast.
The Limitations (The Honest Bit)
1. Maximum 32 permissions (or 53 safely)
JavaScript's bitwise operators work on 32-bit integers. If you use regular numbers (not bitwise ops), you can safely use up to 53 bits (JavaScript's safe integer limit).
Need more? Use BigInt:
const PERMISSIONS = {
PERMISSION_1: 1n << 0n,
PERMISSION_2: 1n << 1n,
// ... up to 64 or more
PERMISSION_65: 1n << 64n,
};
// Check permission (same syntax, just with BigInt)
if (userPerms & PERMISSIONS.PERMISSION_65) {
// Has permission
}
Or use multiple permission integers:
const user = {
permissions1: 0, // Permissions 1-32
permissions2: 0, // Permissions 33-64
};
2. Less readable in database
SELECT id, email, permissions FROM users;
-- Returns: 1, "user@example.com", 87
-- What does 87 mean?
Use the helper functions during development. In production, you don't look at raw permission numbers anyway - you check them programmatically.
3. Can't add permissions dynamically
If users can create custom permissions at runtime, bitmasks won't work. You'd need a different approach (back to the junction table).
This is fine for most apps. Your permission set is usually fixed: read, write, delete, admin, etc.
4. Migration complexity
Migrating from strings to bitmasks requires:
- Mapping old permission strings to bit positions
- Converting existing data
- Running both systems during transition
Not impossible, but it's effort. Plan accordingly.
When to Actually Use This
Use bitmasks when:
- You have a fixed set of permissions (< 32, or < 53 if using regular numbers)
- Permission checks happen frequently (thousands per request)
- Memory efficiency matters (millions of users)
- You want faster authorization without adding Redis/caching complexity
Don't use bitmasks when:
- Permissions are dynamic or user-defined
- You have > 53 permissions and don't want BigInt complexity
- Your team values readability over performance
- You're not hitting performance bottlenecks
- Permissions change frequently (multiple times per day per user)
- You need fine-grained resource-level access (which specific files/folders)
- You need hierarchical permissions (managers inherit team member permissions)
- You need complex conditional logic (can delete IF they created it AND it's < 24hrs old)
For these scenarios, consider:
- Database-backed RBAC with good caching
- Attribute-Based Access Control (ABAC) for complex conditions
- Policy engines like Ory or Casbin
- Zanzibar-style systems for Google-scale authorization
Measure first. If your current approach isn't slow, don't optimize it.
What You've Learned
You now know:
- How to store permissions as a single integer
- How to check permissions with bitwise operations (50% faster than arrays)
- How to save 46% memory compared to multiple booleans
- How to build auth middleware using bitmasks
- How to combine this with JWT/session strategies
- When this approach makes sense (and when it doesn't)
This is what Unix has been doing since the 1970s (chmod 755). This is what game engines use for entity states. This is what network protocols use for flags.
It's not new. It's just... uncommon.
Benchmarks and code: github.com/digitaldrreamer/stat-tests/tree/main/bitmask-benchmarks
Happy Hacking!
Originally published on https://drreamer.digital/blog/using-bitmasks-for-role-based-permissions





Top comments (0)