All articles
Tutorials

Encoding RBAC as a JWT Integer Without Redis

Store role permissions as a single integer in your JWT payload. The permission check becomes one CPU instruction with no database call, no Redis lookup, and a token that is 72% smaller.

Encoding RBAC as a JWT Integer Without Redis cover
11 min read

TL;DR

  • Checking permissions by querying the database on every request turns auth into a linear bottleneck: at 10,000 req/s that is 10,000 auth queries per second. Storing them as a string array in the JWT is O(n) per check and stales the moment permissions change.
  • Encoding permissions as a single integer in the JWT payload eliminates both problems. The check userPerms & REQUIRED_PERM is one CPU instruction, O(1) regardless of how many permissions exist, and requires no external call.
  • The token carrying a bitmask is 72% smaller than one carrying a string array. One INT column in the database replaces a junction table: 10 million users with 5 permissions each means 10 million rows instead of 50 million.
  • The pattern pairs directly with the hybrid JWT/session approach. The bitmask rides in the 15-minute access token; the refresh path re-signs with updated permissions from the database.

Permission systems tend to reach for one of two approaches: query the database on every request for the current permission set, or store permissions as a string array in the JWT and check them with .includes(). Both work at low traffic. Both become a problem as requests scale: the first adds a database round trip to every authenticated call, and the second keeps granting permissions from a stale token.

The alternative is to encode all permissions as a single integer inside the JWT. The permission check becomes a bitwise AND: one CPU instruction, no network call, no cache dependency.

This article covers the permission encoding, the database schema change it enables, the middleware, how it integrates with a hybrid auth setup, and the limitations you need to know before adopting it.

How bitmask encoding works

Each permission is assigned a unique power of two: one bit position that belongs to it and nothing else. The left-shift operator (1 << n) is the standard way to express this, because it makes each constant's position explicit and avoids the transcription errors that come from writing powers of two by hand:

lib/permissions.ts
export 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
} as const;
 
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
 
export function hasPermission(userPerms: number, required: Permission): boolean {
  return (userPerms & required) !== 0;
}
 
export function addPermission(userPerms: number, perm: Permission): number {
  return userPerms | perm;
}
 
export function removePermission(userPerms: number, perm: Permission): number {
  return userPerms & ~perm;
}

A user with CREATE_POSTS (1) and EDIT_OWN_POSTS (2) has permissions = 3. The check 3 & 2 is non-zero, so they have EDIT_OWN_POSTS. The check 3 & 4 is zero, so they do not have EDIT_ALL_POSTS. No loop, no comparison, no allocation.

Database schema for bitmask permissions

The standard approach uses a junction table (one row per user per permission). The row count multiplies:

-- Junction table: 10M users × 5 permissions = 50M rows
CREATE TABLE user_permissions (
  user_id    INT,
  permission VARCHAR(50)
);

With bitmask encoding, you replace the entire table with a single integer column on the users table:

-- Single column: 10M users = 10M rows regardless of permission count
ALTER TABLE users ADD COLUMN permissions INT NOT NULL DEFAULT 0;

Creating a user with an initial permission set combines the relevant constants with OR:

lib/users.ts
import { PERMISSIONS } from "./permissions";
 
const userPerms = PERMISSIONS.CREATE_POSTS | PERMISSIONS.EDIT_OWN_POSTS;
 
await db.query(
  "INSERT INTO users (email, password_hash, permissions) VALUES (?, ?, ?)",
  [email, passwordHash, userPerms]
);

Promoting a user works the same way: OR in the new permissions, then write back the result:

lib/users.ts
const newPerms = user.permissions | PERMISSIONS.DELETE_ALL_POSTS | PERMISSIONS.EDIT_ALL_POSTS;
await db.query("UPDATE users SET permissions = ? WHERE id = ?", [newPerms, userId]);

Permission check middleware

Three authorization shapes cover most route protection requirements. Each reads the permissions integer from the decoded token and runs a bitwise check with no database involved:

middleware/authorize.ts
import { hasPermission, type Permission } from "../lib/permissions";
import type { Request, Response, NextFunction } from "express";
 
interface AuthRequest extends Request {
  user?: { id: string; permissions: number };
}
 
export function requirePermission(required: Permission) {
  return (req: AuthRequest, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: "Unauthorized" });
      return;
    }
    if (!hasPermission(req.user.permissions, required)) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    next();
  };
}
 
export function requireAllPermissions(...required: Permission[]) {
  return (req: AuthRequest, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: "Unauthorized" });
      return;
    }
    const combined = required.reduce((acc, perm) => acc | perm, 0);
    if ((req.user.permissions & combined) !== combined) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    next();
  };
}
 
export function requireAnyPermission(...required: Permission[]) {
  return (req: AuthRequest, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: "Unauthorized" });
      return;
    }
    if (!required.some((perm) => hasPermission(req.user!.permissions, perm))) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    next();
  };
}
Permission check flow: bitmask approach: verify JWT, extract integer, bitwise AND; traditional approach: verify JWT, query database, check row exists

Applying these to routes looks the same as any other Express middleware:

routes/posts.ts
import { PERMISSIONS } from "../lib/permissions";
import { requirePermission, requireAllPermissions } from "../middleware/authorize";
 
app.delete("/posts/:id",
  requirePermission(PERMISSIONS.DELETE_ALL_POSTS),
  deletePostHandler
);
 
app.post("/analytics/export",
  requireAllPermissions(PERMISSIONS.VIEW_ANALYTICS, PERMISSIONS.EXPORT_DATA),
  exportHandler
);

JWT integration and token size

The bitmask encodes cleanly into any JWT payload. Compare the payload size of a string array against an integer for the same four permissions:

// String array in JWT payload: ~180 bytes
{ "userId": 123, "permissions": ["create_posts", "edit_own_posts", "edit_all_posts", "delete_own_posts"] }
 
// Bitmask in JWT payload: ~50 bytes, 72% smaller
{ "userId": 123, "permissions": 15 }
Token size comparison: bitmask payload is ~50 bytes with permissions as a single integer; string array payload is ~180 bytes, 72% larger

Token size matters because every request carries it. Smaller tokens reduce bandwidth and speed up sign and verify operations. HTTP headers have an 8 KB limit; a string array grows with each permission added.

The hybrid JWT/session approach fits this pattern well. The bitmask goes into the short-lived access token (15 minutes); the refresh token is a database record. On login, sign both:

lib/auth.ts
import jwt from "jsonwebtoken";
import crypto from "crypto";
import type { Response } from "express";
 
interface TokenPayload {
  userId: string;
  permissions: number;
}
 
export async function createSession(
  userId: string,
  permissions: number,
  res: Response
): Promise<void> {
  const refreshToken = crypto.randomBytes(32).toString("hex");
 
  await db.session.create({
    data: {
      userId,
      refreshToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });
 
  const accessToken = jwt.sign(
    { userId, permissions } satisfies TokenPayload,
    process.env.JWT_SECRET!,
    { expiresIn: "15m" }
  );
 
  res.cookie("accessToken", accessToken, {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 15 * 60 * 1000,
  });
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 30 * 24 * 60 * 60 * 1000,
  });
}

The protect middleware verifies the access token on the fast path. When it expires, the refresh path re-signs a new one with current data from the database, so any permission changes take effect within one token lifetime:

middleware/protect.ts
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
 
interface TokenPayload { userId: string; permissions: number; }
interface AuthRequest extends Request { user?: TokenPayload; }
 
export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const { accessToken, refreshToken } = req.cookies as {
    accessToken?: string; refreshToken?: string;
  };
 
  try {
    req.user = jwt.verify(accessToken!, process.env.JWT_SECRET!) as TokenPayload;
    next();
    return;
  } catch {
    if (!refreshToken) {
      res.status(401).json({ error: "Not authenticated" });
      return;
    }
 
    const session = await db.session.findUnique({
      where: { refreshToken },
      include: { user: true },
    });
 
    if (!session || session.expiresAt < new Date()) {
      res.status(401).json({ error: "Session expired" });
      return;
    }
 
    const newAccessToken = jwt.sign(
      { userId: session.userId, permissions: session.user.permissions } satisfies TokenPayload,
      process.env.JWT_SECRET!,
      { expiresIn: "15m" }
    );
 
    res.cookie("accessToken", newAccessToken, {
      httpOnly: true, secure: true, sameSite: "lax", maxAge: 15 * 60 * 1000,
    });
    req.user = { userId: session.userId, permissions: session.user.permissions };
    next();
  }
}

When a permission is revoked, update the integer and invalidate the refresh token. The user's current access token stays valid for up to 15 minutes, then the refresh check fails and they re-authenticate with the updated permissions:

lib/permissions.ts
export async function revokePermission(userId: string, perm: Permission): Promise<void> {
  const user = await db.user.findUnique({ where: { id: userId } });
  const newPerms = removePermission(user!.permissions, perm);
 
  await db.$transaction([
    db.user.update({ where: { id: userId }, data: { permissions: newPerms } }),
    db.session.deleteMany({ where: { userId } }),
  ]);
}

Composing roles from permission flags

Rather than assigning individual permissions to every user, define named roles as fixed combinations and assign those. Each role is the OR of its constituent permissions:

lib/roles.ts
import { PERMISSIONS } from "./permissions";
 
export 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,
} as const;
Role hierarchy: individual permission bits compose into VIEWER (3), EDITOR (7), and MODERATOR (31) by OR-ing the relevant bit values

Assigning a role is a single database write. You can still layer individual permissions on top of a role at assignment time:

lib/users.ts
// Assign the moderator role
await db.query("UPDATE users SET permissions = ? WHERE id = ?", [ROLES.MODERATOR, userId]);
 
// Moderator with export access added on
const customPerms = ROLES.MODERATOR | PERMISSIONS.EXPORT_DATA;
 
// Admin without user management
const restrictedAdmin = ROLES.ADMIN & ~PERMISSIONS.MANAGE_USERS;

Security considerations and debugging bitmask permissions

Permission staleness. The bitmask in the access token reflects the permissions at the time it was signed. If you change permissions server-side, the token carries the old values until it expires. The mitigation is short-lived access tokens (5–15 minutes) combined with refresh token invalidation, as shown above.

Bit position stability. A bit position assigned to one permission must never be reassigned to a different one. Old tokens and stored integers carry the old meaning. Treat the permission schema like a database migration: only ever add new positions at the end, never reuse existing ones.

Principle of least privilege. Give roles only the permissions they need. An ADMIN role with every bit set makes it impossible to grant limited administrative access without granting everything.

Audit logging. Log permission failures so you can detect privilege escalation attempts and misconfiguration:

middleware/authorize.ts
if (!hasPermission(req.user.permissions, required)) {
  logger.warn("permission_denied", {
    userId: req.user.id,
    required: debugPermissions(required),
    actual: debugPermissions(req.user.permissions),
    path: req.path,
    method: req.method,
  });
  res.status(403).json({ error: "Forbidden" });
  return;
}

The debugPermissions helper maps a permissions integer back to readable names for log output:

lib/permissions.ts
export function debugPermissions(perms: number): string[] {
  return Object.entries(PERMISSIONS)
    .filter(([, value]) => (perms & value) !== 0)
    .map(([name]) => name);
}
 
// debugPermissions(87) => ["CREATE_POSTS", "EDIT_OWN_POSTS", "DELETE_OWN_POSTS", "MANAGE_USERS"]

When this breaks down

32-permission ceiling. JavaScript's bitwise operators coerce to signed 32-bit integers. You can extend to 53 using regular arithmetic, or use BigInt for more:

lib/permissions.ts
export const PERMISSIONS = {
  PERMISSION_1:  1n << 0n,
  PERMISSION_65: 1n << 64n,
} as const;
 
export const hasPermission = (perms: bigint, required: bigint): boolean =>
  (perms & required) !== 0n;
  • No dynamic permissions. Bit positions must be defined at deploy time. If your product lets customers define their own permission types, you need a junction table with a caching layer instead.

  • Migration cost. Converting an existing string-based permission system requires mapping names to bit positions and backfilling the column. The migration is straightforward but needs a dual-read window during the transition.

  • Highly dynamic permissions. If your system grants and revokes individual permissions multiple times per day per user, the 15-minute staleness window may be too large. In that case, Redis-backed sessions with a permission lookup on each refresh are a better fit. The lookup adds latency, but the revocation is immediate.

  • Resource-level access control. This pattern is not suitable for controlling which specific documents a user can access, hierarchical permissions, or complex conditional logic. For those requirements: Casbin, Ory, or an ABAC policy engine.

Key takeaways

Encoding permissions as a single integer in the JWT payload turns every permission check into one bitwise AND instruction with no database call, no Redis lookup, and no network round trip. A bitmask token is 72% smaller than one carrying a string array, and one INT column in the database replaces an entire junction table: at 10 million users with 5 permissions each, that is 10 million rows instead of 50 million. Bit positions are permanent once assigned: adding permissions is safe, but reusing a position corrupts every stored integer and every live token that carries the old encoding. The pattern is not a fit for resource-level access control, highly dynamic permission systems where revocation must be immediate, or permission sets larger than 31 entries unless you move to BigInt arithmetic.

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Encoding RBAC as a JWT Integer Without Redis | Reclear