JSON API Access Control: RBAC Policies, Field-Level Permissions & Casbin

Last updated:

Most access control guides focus on authentication — verifying who a user is. This guide covers authorization — controlling what JSON data they can see and modify. JSON API access control has three distinct layers: role-level permissions (can this role perform this action?), resource-level ownership (does this user own this specific resource?), and field-level filtering (which JSON fields should this role receive in the response?). Getting any one of these wrong produces an OWASP API Security vulnerability. This guide covers RBAC JSON policy format, field-level permission filtering, resource ownership patterns, Casbin policy CSV and JSON adapters, ABAC with OPA, JWT claims for authorization, and testing strategies for verifying access control correctness.

RBAC JSON Policy Format

RBAC (Role-Based Access Control) assigns permissions to roles, then assigns roles to users. The key design decision is how to represent this in JSON — a format that is auditable, serializable, and easy to enforce in middleware. A well-structured RBAC policy separates role definitions (what each role can do) from role assignments (which users have which roles).

// ── RBAC policy document (rbac-policy.json) ──────────────────────────
{
  "roles": {
    "admin": {
      "description": "Full access to all resources",
      "inherits": ["editor"],
      "permissions": [
        { "resource": "user",    "action": "delete" },
        { "resource": "user",    "action": "list" },
        { "resource": "post",    "action": "delete" },
        { "resource": "setting", "action": "update" }
      ]
    },
    "editor": {
      "description": "Create and update content",
      "inherits": ["viewer"],
      "permissions": [
        { "resource": "post", "action": "create" },
        { "resource": "post", "action": "update" },
        { "resource": "media", "action": "upload" }
      ]
    },
    "viewer": {
      "description": "Read-only access to published content",
      "inherits": [],
      "permissions": [
        { "resource": "post",    "action": "read" },
        { "resource": "comment", "action": "read" }
      ]
    }
  }
}

// ── Flattening role hierarchy at startup ──────────────────────────────
// Load once, cache in memory — O(1) permission lookups at request time
type Permission = { resource: string; action: string }
type PolicyMap = Record<string, Set<string>>

function buildPermissionMap(policyJson: typeof import('./rbac-policy.json')): PolicyMap {
  const map: PolicyMap = {}

  function flatten(roleName: string, visited = new Set<string>()): Permission[] {
    if (visited.has(roleName)) return []        // guard against circular inherits
    visited.add(roleName)
    const role = policyJson.roles[roleName as keyof typeof policyJson.roles]
    if (!role) return []

    const inherited = role.inherits.flatMap(parent => flatten(parent, visited))
    return [...inherited, ...role.permissions]
  }

  for (const roleName of Object.keys(policyJson.roles)) {
    const perms = flatten(roleName)
    map[roleName] = new Set(perms.map(p => `${p.resource}:${p.action}`))
  }

  return map
}

// ── Example flattened result ──────────────────────────────────────────
// admin  → Set{ "post:read","comment:read","post:create","post:update",
//               "media:upload","user:delete","user:list","post:delete",
//               "setting:update" }
// editor → Set{ "post:read","comment:read","post:create","post:update",
//               "media:upload" }
// viewer → Set{ "post:read","comment:read" }

// ── Enforcement function ──────────────────────────────────────────────
const PERM_MAP = buildPermissionMap(rbacPolicy)

function can(role: string, resource: string, action: string): boolean {
  return PERM_MAP[role]?.has(`${resource}:${action}`) ?? false
}

// ── Express middleware ────────────────────────────────────────────────
function requirePermission(resource: string, action: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const role = req.user?.role      // from verified JWT
    if (!role || !can(role, resource, action)) {
      return res.status(403).json({
        error: 'Forbidden',
        required: `${resource}:${action}`,
        role: role ?? 'unauthenticated',
      })
    }
    next()
  }
}

// ── Route usage ───────────────────────────────────────────────────────
app.delete('/posts/:id',
  requirePermission('post', 'delete'),
  deletePostHandler
)
app.get('/users',
  requirePermission('user', 'list'),
  listUsersHandler
)

Flatten the role hierarchy once at application startup — not on every request. Store the flattened Set per role in memory; permission checks become O(1) hash lookups. Keep the source JSON policy in version control so that permission changes go through code review. For validating the policy JSON structure against a schema at startup, see the linked guide.

Field-Level Permission Filtering in JSON Responses

Field-level filtering is the most frequently violated access control layer. OWASP API Security Top 10 — API3 (Excessive Data Exposure) describes APIs that return complete database objects and expect clients to hide sensitive fields. The fix is simple: apply a server-side projection function before serializing the response. Use allowlists, not denylists — a denylist breaks silently when a new sensitive field is added to the model.

// ── Field allowlist policy ─────────────────────────────────────────────
// Define which fields each role is allowed to see
const USER_FIELD_POLICY: Record<string, string[]> = {
  admin:  ['id', 'email', 'displayName', 'role', 'salary', 'ssn',
           'internalNotes', 'createdAt', 'lastLoginAt'],
  editor: ['id', 'email', 'displayName', 'role', 'createdAt'],
  viewer: ['id', 'displayName'],
}

// ── Projection function (top-level fields) ─────────────────────────────
function projectUserFields(user: Record<string, unknown>, role: string) {
  const allowed = USER_FIELD_POLICY[role] ?? []
  return Object.fromEntries(
    Object.entries(user).filter(([key]) => allowed.includes(key))
  )
}

// ── Example: what each role sees for the same user record ─────────────
const rawUser = {
  id: 42,
  email: 'alice@example.com',
  displayName: 'Alice',
  role: 'editor',
  salary: 95000,
  ssn: '555-12-3456',
  internalNotes: 'High performer, flagged for promotion',
  createdAt: '2025-01-15T10:00:00Z',
  lastLoginAt: '2026-05-20T08:30:00Z',
}

projectUserFields(rawUser, 'admin')
// => { id, email, displayName, role, salary, ssn,
//      internalNotes, createdAt, lastLoginAt }  (all fields)

projectUserFields(rawUser, 'editor')
// => { id, email, displayName, role, createdAt }

projectUserFields(rawUser, 'viewer')
// => { id, displayName }

// ── Recursive projection for nested objects ──────────────────────────
const POST_FIELD_POLICY: Record<string, (string | { field: string; subPolicy: string })[]> = {
  admin:  ['id', 'title', 'body', 'status', 'internalScore',
           { field: 'author', subPolicy: 'editor' }],
  editor: ['id', 'title', 'body', 'status',
           { field: 'author', subPolicy: 'editor' }],
  viewer: ['id', 'title', 'body',
           { field: 'author', subPolicy: 'viewer' }],
}

// ── Apply at the ORM / SQL layer when possible ───────────────────────
// More efficient: only fetch allowed columns from DB
async function getUserForRole(userId: number, requestingRole: string) {
  const allowedColumns = USER_FIELD_POLICY[requestingRole] ?? ['id', 'displayName']

  // Prisma: select only what the role is allowed to see
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: Object.fromEntries(allowedColumns.map(col => [col, true])),
  })

  return user  // already filtered — no sensitive fields ever fetched
}

// ── Express handler pattern ──────────────────────────────────────────
app.get('/users/:id', authenticate, async (req, res) => {
  const role = req.user!.role
  const user = await getUserForRole(Number(req.params.id), role)

  if (!user) return res.status(404).json({ error: 'Not found' })

  // If fetching the full record is unavoidable, project before responding:
  res.json(projectUserFields(user, role))
  // NEVER: res.json(user)  — returns all fields regardless of role
})

The most efficient approach is to apply the projection at the database query level — fetch only the columns the role is permitted to see. This prevents sensitive fields from ever entering memory, appearing in logs, or being accidentally returned by a future code change. The in-memory projectUserFields function is a safety net, not the primary defense. For JSON filtering and transformation patterns, see the linked guide.

Resource-Level JSON Access Control

Role permissions answer "can editors update posts?" Resource ownership answers "can this editor update this specific post?" Both checks are required. An editor who passes the role check but does not own the resource must still be denied. Return 404 (Not Found) instead of 403 (Forbidden) for resources the requesting user should not know exist — this prevents resource enumeration attacks.

// ── Ownership check pattern ───────────────────────────────────────────
interface Post {
  id: number
  ownerId: number
  teamIds: number[]
  projectId: number
  status: 'draft' | 'published'
  body: string
}

interface AuthUser {
  id: number
  role: 'admin' | 'editor' | 'viewer'
  teamIds: number[]
}

async function getPostWithOwnershipCheck(
  user: AuthUser,
  postId: number
): Promise<Post> {
  // Enforce ownership in the WHERE clause — not in application code
  // This prevents timing side channels and is more efficient
  const post = await db.query<Post>(
    `SELECT * FROM posts
     WHERE id = $1
       AND (
         owner_id = $2            -- direct ownership
         OR $3 = 'admin'          -- admins bypass ownership
         OR EXISTS (              -- team-based access
           SELECT 1 FROM post_teams pt
           WHERE pt.post_id = posts.id
             AND pt.team_id = ANY($4)
         )
       )`,
    [postId, user.id, user.role, user.teamIds]
  )

  if (!post) {
    // Return 404 even if the post exists but user lacks access
    // — prevents resource enumeration (attacker cannot distinguish
    //   "post doesn't exist" from "post exists but you can't see it")
    throw new NotFoundError(`Post ${postId} not found`)
  }

  return post
}

// ── Hierarchical resource permissions ────────────────────────────────
// Comment -> Post -> Project: check the full ownership chain
async function getCommentWithHierarchyCheck(
  user: AuthUser,
  commentId: number
): Promise<Comment> {
  const result = await db.query(
    `SELECT c.*, p.owner_id AS post_owner_id, proj.owner_id AS project_owner_id
     FROM comments c
     JOIN posts p ON p.id = c.post_id
     JOIN projects proj ON proj.id = p.project_id
     WHERE c.id = $1
       AND (
         c.author_id = $2          -- authored the comment
         OR p.owner_id = $2        -- owns the parent post
         OR proj.owner_id = $2     -- owns the parent project
         OR $3 = 'admin'           -- admin bypass
       )`,
    [commentId, user.id, user.role]
  )

  if (!result) throw new NotFoundError(`Comment ${commentId} not found`)
  return result
}

// ── JSON response for authorization failure (403) ────────────────────
// Use 403 only when it is safe to reveal that the resource exists
// (e.g., user is authenticated and trying to access a shared resource)
function forbiddenResponse(resource: string, action: string) {
  return {
    error: 'Forbidden',
    message: `You do not have permission to ${action} this ${resource}`,
    // Do NOT include the resource ID or owner — leaks information
  }
}

// ── Middleware pattern: combine role + ownership check ─────────────────
function requirePostAccess(action: 'read' | 'update' | 'delete') {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = req.user!

    // Step 1: role-level check
    if (!can(user.role, 'post', action)) {
      return res.status(403).json(forbiddenResponse('post', action))
    }

    // Step 2: resource-level ownership check
    try {
      const post = await getPostWithOwnershipCheck(user, Number(req.params.id))
      req.resource = post   // attach for handler use
      next()
    } catch (err) {
      if (err instanceof NotFoundError) {
        return res.status(404).json({ error: 'Not found' })
      }
      next(err)
    }
  }
}

app.put('/posts/:id', authenticate, requirePostAccess('update'), updatePostHandler)
app.delete('/posts/:id', authenticate, requirePostAccess('delete'), deletePostHandler)

Embedding ownership checks directly in the SQL WHERE clause is the most secure pattern: the database engine enforces the constraint atomically, and the application never touches a record it should not see. Application-level ownership checks (fetch then compare) are subject to TOCTOU (time-of-check-time-of-use) races in concurrent environments. Always log denied access attempts with the user ID, resource ID, and action — these are the breadcrumbs needed for incident response.

Casbin RBAC Policy in JSON/CSV Format

Casbin is a policy-as-code authorization library that decouples the access control model from the policy data. It evaluates an enforce(subject, object, action) call against the loaded model and policy, returning a boolean. Casbin supports RBAC (with role inheritance), ABAC, ACL, and ReBAC in the same library — the model determines the semantics.

# ── model.conf: defines the structure of the policy ──────────────────
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _           # g(user, role) or g(role, parent-role)

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

# ── policy.csv: the actual access control rules ───────────────────────
# p lines: (role/user, resource, action) — allow rules
p, viewer, post, read
p, viewer, comment, read
p, editor, post, create
p, editor, post, update
p, editor, media, upload
p, admin, user, delete
p, admin, user, list
p, admin, post, delete
p, admin, setting, update

# g lines: role inheritance (child inherits all parent permissions)
g, editor, viewer     # editor inherits viewer permissions
g, admin, editor      # admin inherits editor permissions

# User-to-role assignments
g, alice, editor
g, bob,   viewer
g, carol, admin

# Resource-specific overrides: alice can delete her own posts
p, alice, post:alice-owned, delete

// ── JSON adapter (alternative to policy.csv) ─────────────────────────
// Casbin JSON adapter format — easier to manage programmatically
const casbinPolicies = {
  "policies": [
    { "type": "p", "sub": "viewer",  "obj": "post",    "act": "read"   },
    { "type": "p", "sub": "viewer",  "obj": "comment", "act": "read"   },
    { "type": "p", "sub": "editor",  "obj": "post",    "act": "create" },
    { "type": "p", "sub": "editor",  "obj": "post",    "act": "update" },
    { "type": "p", "sub": "admin",   "obj": "user",    "act": "delete" },
    { "type": "p", "sub": "admin",   "obj": "post",    "act": "delete" }
  ],
  "groupPolicies": [
    { "type": "g", "sub": "editor", "parent": "viewer" },
    { "type": "g", "sub": "admin",  "parent": "editor" },
    { "type": "g", "sub": "alice",  "parent": "editor" },
    { "type": "g", "sub": "carol",  "parent": "admin"  }
  ]
}

// ── Node.js Casbin setup ──────────────────────────────────────────────
import { newEnforcer, newModelFromString } from 'casbin'
import { JsonAdapter } from 'casbin-json-adapter'

const MODEL = `
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
`

async function createEnforcer() {
  const model = await newModelFromString(MODEL)
  const adapter = new JsonAdapter(JSON.stringify(casbinPolicies))
  return newEnforcer(model, adapter)
}

const enforcer = await createEnforcer()

// ── Enforce a permission ──────────────────────────────────────────────
const allowed = await enforcer.enforce('alice', 'post', 'create') // true (editor)
const denied  = await enforcer.enforce('bob',   'post', 'create') // false (viewer)
const admin   = await enforcer.enforce('carol', 'user', 'delete') // true (admin)

// ── Express middleware with Casbin ────────────────────────────────────
function casbinGuard(resource: string, action: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const subject = req.user?.id ?? 'anonymous'
    const allowed = await enforcer.enforce(subject, resource, action)
    if (!allowed) {
      return res.status(403).json({ error: 'Forbidden' })
    }
    next()
  }
}

app.delete('/posts/:id', authenticate, casbinGuard('post', 'delete'), handler)

Casbin's g (role inheritance) lines eliminate the need to enumerate permissions per user — define roles once, assign users to roles. The JSON adapter is preferable to CSV in production because it is programmatically writable: you can load policies from a database, modify them at runtime with enforcer.addPolicy(), and persist changes back without restarting the process. For JWT-based subject identification to pass as the Casbin subject, see the linked guide.

Attribute-Based Access Control (ABAC) in JSON

ABAC evaluates access based on attributes of the subject, resource, and environment — not just the subject's role. It is more expressive than RBAC but harder to enumerate: you cannot easily list all resources a given user can access without evaluating every resource against the policy. OPA (Open Policy Agent) is the standard ABAC engine for JSON APIs — it evaluates Rego policy files against a JSON input document.

// ── ABAC policy document (JSON format) ──────────────────────────────
// Encodes conditional rules that RBAC cannot express
const abacPolicy = {
  rules: [
    {
      id: "editor-can-update-own-draft",
      description: "Editors can update their own draft posts",
      conditions: {
        subject:  { role: "editor" },
        resource: { type: "post", status: "draft", ownerId: "{subject.userId{'}'}" },
        action:   "update",
      },
      effect: "allow"
    },
    {
      id: "marketing-can-publish",
      description: "Marketing editors can publish posts in their department",
      conditions: {
        subject:  { role: "editor", department: "marketing" },
        resource: { type: "post", department: "marketing" },
        action:   "publish",
      },
      effect: "allow"
    },
    {
      id: "business-hours-only",
      description: "Sensitive updates only during business hours",
      conditions: {
        subject:  { role: "editor" },
        resource: { type: "financial-record" },
        action:   "update",
        environment: { hourUTC: { gte: 9, lte: 17 } }
      },
      effect: "allow"
    }
  ]
}

// ── OPA (Open Policy Agent) Rego policy ──────────────────────────────
// policy.rego — evaluated against a JSON input document
/*
package api.authz

import future.keywords.if
import future.keywords.in

# Default deny
default allow := false

# Allow if role has the permission (RBAC baseline)
allow if {
  some perm in data.role_permissions[input.subject.role]
  perm.resource == input.resource.type
  perm.action   == input.action
}

# ABAC rule: editors can update their own draft posts
allow if {
  input.subject.role         == "editor"
  input.action               == "update"
  input.resource.type        == "post"
  input.resource.status      == "draft"
  input.resource.ownerId     == input.subject.userId
}

# ABAC rule: marketing editors can publish marketing posts
allow if {
  input.subject.role         == "editor"
  input.subject.department   == "marketing"
  input.action               == "publish"
  input.resource.type        == "post"
  input.resource.department  == "marketing"
}
*/

// ── OPA input document (JSON sent to OPA for evaluation) ─────────────
const opaInput = {
  subject: {
    userId:     "user-42",
    role:       "editor",
    department: "marketing",
  },
  resource: {
    type:        "post",
    id:          "post-99",
    status:      "draft",
    ownerId:     "user-42",
    department:  "marketing",
  },
  action: "update",
  environment: {
    ipAddress:  "10.0.1.5",
    hourUTC:    14,
    region:     "us-east-1",
  }
}

// ── Query OPA via HTTP API ────────────────────────────────────────────
async function checkWithOpa(input: typeof opaInput): Promise<boolean> {
  const response = await fetch('http://opa-service:8181/v1/data/api/authz/allow', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ input }),
  })

  // OPA returns: { "result": true } or { "result": false }
  const { result } = await response.json() as { result: boolean }
  return result
}

// ── Express middleware using OPA ──────────────────────────────────────
function opaGuard(buildInput: (req: Request) => typeof opaInput) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const input = buildInput(req)
    const allowed = await checkWithOpa(input)

    if (!allowed) {
      return res.status(403).json({
        error: 'Forbidden',
        // Do NOT echo back the OPA input — may contain sensitive attributes
      })
    }
    next()
  }
}

app.put('/posts/:id', authenticate, opaGuard(req => ({
  subject:     { userId: req.user!.id, role: req.user!.role, department: req.user!.department },
  resource:    { type: 'post', id: req.params.id, ...req.resource },
  action:      'update',
  environment: { hourUTC: new Date().getUTCHours(), ipAddress: req.ip ?? '' },
})), updatePostHandler)

OPA decouples the policy from the application — policies are Rego files versioned in a separate repository, deployed to OPA sidecars without application restarts. The input document is a JSON object constructed from request context; the decision output is JSON {"result": true}. Cache OPA decisions for identical input documents (keyed by a hash of the input) to avoid a network call on every request. For a comprehensive JSON API security overview including injection prevention, see the linked guide.

JWT Claims for JSON API Authorization

JWT (JSON Web Token) carries authorization claims in its payload — a base64-encoded JSON object signed with a private key. Standard claims handle identity and token lifecycle; custom claims carry role and permission data. The critical constraint: JWTs are signed, not encrypted. Anyone who receives the token can decode the payload. Never put sensitive user data in JWT claims.

// ── Standard JWT payload structure ──────────────────────────────────
{
  // Standard registered claims (RFC 7519)
  "iss": "https://auth.example.com",      // issuer — your auth server
  "sub": "user-42",                       // subject — the user ID
  "aud": "https://api.example.com",       // audience — your API
  "exp": 1748000000,                      // expiry (Unix timestamp)
  "iat": 1747999700,                      // issued at
  "jti": "tok-a1b2c3d4",                  // unique token ID (for revocation)

  // Custom authorization claims
  "role":        "editor",                // single role (simple RBAC)
  "roles":       ["editor", "reviewer"],  // multiple roles
  "orgId":       "org-99",               // tenant identifier
  "permissions": [                        // explicit permission list
    "post:create",
    "post:update",
    "media:upload"
  ]
  // Keep permissions list < 20 items to stay within 4KB header/cookie limits
  // For granular permissions, store them in DB and look up by role
}

// ── Issuing a JWT (Node.js, jsonwebtoken) ─────────────────────────────
import jwt from 'jsonwebtoken'

function issueAccessToken(user: User): string {
  return jwt.sign(
    {
      // Only put what is needed for authorization decisions
      role:   user.role,
      orgId:  user.orgId,
      // Do NOT include: email, name, salary, SSN, PII
      // Those go in a separate ID token or profile endpoint
    },
    process.env.JWT_PRIVATE_KEY!,
    {
      algorithm: 'RS256',         // asymmetric — verifiers only need the public key
      subject:   user.id,         // 'sub' claim
      issuer:    'https://auth.example.com',
      audience:  'https://api.example.com',
      expiresIn: '15m',           // short-lived access token
    }
  )
}

// ── Verifying and extracting claims in API middleware ─────────────────
import { expressjwt } from 'express-jwt'
import jwksRsa from 'jwks-rsa'

// Use JWKS endpoint for automatic key rotation — no manual key distribution
const authenticate = expressjwt({
  secret: jwksRsa.expressJwtSecret({
    jwksUri: 'https://auth.example.com/.well-known/jwks.json',
    cache: true,
    rateLimit: true,
  }),
  algorithms: ['RS256'],
  audience:   'https://api.example.com',
  issuer:     'https://auth.example.com',
})

// After authenticate middleware, req.auth contains the verified payload:
// req.auth = { sub: "user-42", role: "editor", orgId: "org-99", ... }

// ── Multi-tenant: scope claims to the org ─────────────────────────────
function requireSameOrg(req: Request, res: Response, next: NextFunction) {
  const requestedOrgId = req.params.orgId
  if (req.auth?.orgId !== requestedOrgId && req.auth?.role !== 'superadmin') {
    return res.status(403).json({ error: 'Cross-tenant access denied' })
  }
  next()
}

// ── Avoiding over-broad permissions ───────────────────────────────────
// BAD: a single wildcard permission — one token compromise = full access
{ "permissions": ["*"] }
{ "permissions": ["admin:*"] }

// GOOD: granular, minimal permissions
{ "permissions": ["post:read", "post:create"] }

// ── Token revocation via jti claim ───────────────────────────────────
// Store revoked token IDs in Redis with TTL = token expiry
async function revokeToken(jti: string, expiresAt: Date) {
  const ttlSeconds = Math.ceil((expiresAt.getTime() - Date.now()) / 1000)
  await redis.set(`revoked_token:${jti}`, '1', 'EX', ttlSeconds)
}

async function isTokenRevoked(jti: string): Promise<boolean> {
  return (await redis.exists(`revoked_token:${jti}`)) === 1
}

// Add revocation check after signature verification:
async function verifyAndCheck(token: string) {
  const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] })
  if (typeof payload !== 'object' || !payload.jti) throw new Error('Invalid token')
  if (await isTokenRevoked(payload.jti)) throw new Error('Token revoked')
  return payload
}

Use RS256 (asymmetric) rather than HS256 (symmetric) for JWT signing — with RS256, each microservice verifies tokens using only the auth server's public key without sharing a secret. Publish the public key at a JWKS endpoint (/.well-known/jwks.json) for automatic key rotation. Set access token expiry to 15 minutes; use refresh tokens (stored in HttpOnly cookies) for session continuity. For a deeper dive on JWT best practices including algorithm selection, see the linked guide.

Testing JSON Access Control

Access control bugs are high-severity and hard to find with happy-path testing alone. Three complementary strategies cover the space: permission matrix tests (every role/resource/action triple), property-based tests for privilege escalation invariants, and snapshot tests for filtered response shapes.

// ── 1. Permission matrix test ─────────────────────────────────────────
// Exhaustively test every (role, resource, action) combination
import { describe, it, expect } from 'vitest'
import { can } from '../rbac'

const PERMISSION_MATRIX = [
  // [role,   resource,   action,    expected]
  ['viewer',  'post',     'read',    true  ],
  ['viewer',  'post',     'create',  false ],
  ['viewer',  'post',     'delete',  false ],
  ['viewer',  'user',     'list',    false ],
  ['editor',  'post',     'read',    true  ],
  ['editor',  'post',     'create',  true  ],
  ['editor',  'post',     'update',  true  ],
  ['editor',  'post',     'delete',  false ],
  ['editor',  'user',     'list',    false ],
  ['admin',   'post',     'delete',  true  ],
  ['admin',   'user',     'list',    true  ],
  ['admin',   'user',     'delete',  true  ],
  ['admin',   'setting',  'update',  true  ],
] as const

describe('RBAC permission matrix', () => {
  it.each(PERMISSION_MATRIX)(
    '%s can %s %s: %s',
    (role, resource, action, expected) => {
      expect(can(role, resource, action)).toBe(expected)
    }
  )
})

// ── 2. Property-based test: no privilege escalation ───────────────────
// Assert: for all (role, resource, action), if viewer is denied,
// no lower-privileged role can be allowed
import fc from 'fast-check'

const ROLES_BY_PRIVILEGE = ['viewer', 'editor', 'admin']  // ascending privilege

describe('Privilege escalation invariant', () => {
  it('no role has more permissions than a higher-privilege role', () => {
    fc.assert(fc.property(
      fc.constantFrom('post', 'user', 'comment', 'setting'),
      fc.constantFrom('read', 'create', 'update', 'delete', 'list'),
      (resource, action) => {
        // Find the highest privilege level that is allowed
        const allowedFromIndex = ROLES_BY_PRIVILEGE.findIndex(
          role => can(role, resource, action)
        )

        if (allowedFromIndex === -1) return true  // nobody can — fine

        // Assert: all higher-privilege roles are also allowed
        for (let i = allowedFromIndex + 1; i < ROLES_BY_PRIVILEGE.length; i++) {
          const higherRole = ROLES_BY_PRIVILEGE[i]
          if (!can(higherRole, resource, action)) {
            throw new Error(
              `Privilege escalation gap: ${ROLES_BY_PRIVILEGE[allowedFromIndex]}` +
              ` can ${action} ${resource} but ${higherRole} cannot`
            )
          }
        }
        return true
      }
    ))
  })
})

// ── 3. Snapshot test: filtered response fields ────────────────────────
// Catch new sensitive fields being accidentally exposed
import { app } from '../app'
import request from 'supertest'

describe('Field-level filtering snapshots', () => {
  const USER_ID = 42

  it('viewer receives minimal user fields', async () => {
    const token = issueTestToken({ sub: 'user-1', role: 'viewer' })
    const res = await request(app)
      .get(`/users/${USER_ID}`)
      .set('Authorization', `Bearer ${token}`)
      .expect(200)

    // Snapshot freezes the exact shape — fails if a new field is added
    expect(res.body).toMatchSnapshot()
    // Explicitly assert sensitive fields are absent:
    expect(res.body).not.toHaveProperty('salary')
    expect(res.body).not.toHaveProperty('ssn')
    expect(res.body).not.toHaveProperty('internalNotes')
    expect(res.body).not.toHaveProperty('email')
  })

  it('admin receives all user fields', async () => {
    const token = issueTestToken({ sub: 'admin-1', role: 'admin' })
    const res = await request(app)
      .get(`/users/${USER_ID}`)
      .set('Authorization', `Bearer ${token}`)
      .expect(200)

    expect(res.body).toMatchSnapshot()
    expect(res.body).toHaveProperty('salary')
    expect(res.body).toHaveProperty('ssn')
  })
})

// ── 4. Negative tests: authentication and authorization failures ───────
describe('Access control negative cases', () => {
  it('rejects requests without a token (401)', async () => {
    await request(app).get('/users/42').expect(401)
  })

  it('rejects expired tokens (401)', async () => {
    const expiredToken = issueTestToken({ sub: 'user-1', role: 'editor' }, { expiresIn: '-1s' })
    await request(app)
      .get('/users/42')
      .set('Authorization', `Bearer ${expiredToken}`)
      .expect(401)
  })

  it('viewer cannot access admin endpoints (403)', async () => {
    const token = issueTestToken({ sub: 'user-1', role: 'viewer' })
    await request(app)
      .delete('/posts/1')
      .set('Authorization', `Bearer ${token}`)
      .expect(403)
  })

  it('editor cannot access another user posts (404, not 403)', async () => {
    // User 99 does not own post-1; should get 404 to prevent enumeration
    const token = issueTestToken({ sub: 'user-99', role: 'editor' })
    await request(app)
      .put('/posts/1')
      .set('Authorization', `Bearer ${token}`)
      .send({ title: 'Hijacked' })
      .expect(404)   // NOT 403 — don't reveal the resource exists
  })
})

Run all access control tests against a real test database with seeded data — mocking the auth layer hides bugs where the actual database query does not enforce ownership correctly. Generate test tokens with a test-only signing key (issueTestToken), never with production keys. Add the full permission matrix test to CI so that every PR that modifies RBAC policy must pass the complete matrix — this makes permission regressions impossible to merge silently. For JSON API testing strategies including contract testing, see the linked guide.

Key Terms

RBAC (Role-Based Access Control)
An access control model that assigns permissions to roles, then assigns roles to users. A user's permissions are the union of all permissions across their assigned roles. RBAC simplifies administration — changing a role definition immediately updates permissions for all users with that role. Role hierarchy (inheritance) allows child roles to inherit parent permissions: an editor inheriting from viewer automatically gets all viewer permissions plus editor-specific ones. RBAC is the right model when access requirements can be expressed as a small number of discrete roles with well-defined permission sets. The limitation of RBAC is expressiveness: permissions that depend on resource attributes (status, ownership, classification) or environmental context (time, location) cannot be expressed without a combinatorial explosion of roles. For those cases, augment RBAC with resource-level ownership checks or switch to ABAC. Represent RBAC policies as JSON documents in version control so that permission changes are auditable through code review.
ABAC (Attribute-Based Access Control)
An access control model that evaluates access based on attributes of the subject (user), resource, and environment at request time — not just the subject's role. An ABAC policy might allow update if subject.role is editor AND resource.status is draft AND resource.ownerId equals subject.userId AND environment.hourUTC is between 9 and 17. This single rule replaces what would be multiple RBAC roles. ABAC is more expressive and flexible than RBAC but harder to reason about: you cannot enumerate all resources a given user can access without evaluating every resource. OPA (Open Policy Agent) is the most popular ABAC engine — it evaluates Rego policy files against JSON input documents. Use ABAC when permissions depend on resource attributes, environmental conditions, or complex multi-attribute combinations that RBAC cannot express cleanly. In practice, most systems use RBAC as a baseline and add ABAC-style resource-level checks for ownership and attribute conditions.
Field-Level Permission
An access control mechanism that controls which fields of a JSON object a given role is allowed to read or write. Field-level filtering must happen on the server before the response is serialized — OWASP API Security Top 10 (API3: Excessive Data Exposure) specifically identifies APIs that return full objects and rely on clients to hide sensitive fields as a critical vulnerability. Implement field-level permissions using an allowlist (explicit set of permitted fields per role) rather than a denylist (excluded fields) — denylists silently fail when new sensitive fields are added to the data model. Apply filtering at the database query level (SELECT only permitted columns) for maximum efficiency; apply it again in the response serializer as a defense-in-depth safety net. For write operations, also validate that the request body does not contain fields the role is not permitted to update.
Resource Ownership
A resource-level access control check that verifies a user has a direct relationship with a specific resource before allowing access — independent of their role-level permissions. Even if a user's role grants the action type (e.g., editor can update posts), resource ownership checks ensure they can only update posts they created or are explicitly granted access to. Ownership checks are best implemented at the database query level — include ownership criteria in the SQL WHERE clause rather than fetching the resource and comparing in application code. This prevents timing side channels, is more efficient, and is enforced atomically by the database. Return 404 (Not Found) rather than 403 (Forbidden) for resources a user cannot access if revealing the resource's existence would leak information (resource enumeration attack). Log all ownership check failures with the requesting user ID, resource ID, and action for incident forensics.
Casbin
An open-source authorization library that implements RBAC, ABAC, ACL, and ReBAC through a unified model-and-policy architecture. The access control model is defined in model.conf using sections: request definition (r), policy definition (p), role definition (g), policy effect (e), and matchers (m). The policy data is defined separately in policy.csv or through an adapter (JSON, database, Redis). Casbin evaluates enforce(subject, object, action) calls in memory using the loaded model and policy, returning a boolean decision in microseconds. Role inheritance is defined with g (group) lines: g, editor, viewer means editor inherits all viewer permissions. Casbin's JSON adapter stores the same policy data in JSON format, making it easier to load from a database or modify programmatically at runtime with enforcer.addPolicy(). Casbin is a good fit when policy rules are complex enough that hand-coded permission checks become unmaintainable.
OPA (Open Policy Agent)
An open-source, general-purpose policy engine that evaluates Rego language policies against JSON input documents and returns JSON decisions. OPA is the standard ABAC engine for cloud-native APIs. It runs as a sidecar or standalone service — the API sends a JSON input document (subject attributes, resource attributes, action, environment) to OPA's REST API and receives a JSON response: {"result": true} or {"result": false}. Rego policies are declarative rules that can express complex attribute-based conditions. OPA decouples policy from application code: policies are versioned separately, deployed independently, and can be updated without application restarts. The input document schema is the contract between the application and OPA — both sides must agree on the field names and structure. Cache OPA decisions keyed by a hash of the input document to avoid a network round-trip on every request for identical conditions.
JWT Claims
Key-value pairs in the JWT payload that carry identity and authorization information. Registered claims (defined in RFC 7519): sub (subject/user ID), iss (issuer URL), aud (audience/API identifier), exp (expiry timestamp), iat (issued-at timestamp), jti (unique token ID for revocation). Custom claims extend the standard set for authorization: role (user's RBAC role), permissions (array of allowed actions), orgId (tenant identifier for multi-tenant APIs). JWT payloads are base64-encoded JSON — not encrypted — so any party with the token can decode and read the claims. Never put PII, passwords, or sensitive user data in JWT claims; use JWE (JSON Web Encryption) if claims must be confidential. Keep custom permission arrays under 20 items to stay within the practical 4KB limit of HTTP headers and cookies. For session continuity, pair short-lived access tokens (15-minute expiry, claims in JWT) with long-lived refresh tokens (stored in HttpOnly cookies, no sensitive claims).
Privilege Escalation
A security vulnerability where a lower-privileged user gains access to actions or data that should require higher privileges. In JSON API access control, privilege escalation can occur in several ways: a missing RBAC rule allows a viewer to call an admin endpoint; a field-level filtering bug exposes a sensitive field to a lower-privileged role; a resource ownership check is bypassed through a crafted resource ID; a JWT claim is not verified and can be modified by the client; or a role hierarchy is incorrectly flattened so a child role gains permissions it should not inherit. Test for privilege escalation with a complete permission matrix (assert every denied combination is actually denied) and property-based tests that verify no lower-privileged role can perform any action a higher-privileged role cannot. Monitor for privilege escalation in production by alerting on sequences of 403 responses from the same client — this pattern indicates a probing attack.

FAQ

How do I implement role-based access control (RBAC) for a JSON API?

Define roles (admin, editor, viewer) as JSON objects with permission arrays. Each permission specifies a resource and action pair: {"role":"editor","permissions":[{"resource":"post","action":"create"}]}. Store role assignments in the database or in the JWT payload. On each API request, extract the verified role from the JWT, load the flattened permission set (cache in Redis), then check whether the required (resource, action) pair is in the set before processing the request. Implement role hierarchy by flattening parent role permissions into the child's set at policy load time — do this once at startup, not per request. Keep all role definitions in a single authoritative location — a JSON file in version control, a database table, or a policy-as-code system like OPA — never scattered across individual route handlers. Audit every denied request with the user ID, role, resource, and action for forensic analysis. Never implement the permission check on the client side alone — always enforce server-side.

How do I filter JSON response fields based on the user's role or permissions?

Use server-side allowlists: define which fields each role is permitted to receive, then apply a projection function before calling res.json(). Example: const allowed = FIELD_POLICY[role] ?? []; return Object.fromEntries(Object.entries(obj).filter(([k]) => allowed.includes(k))). Use allowlists (explicit permitted fields), not denylists (excluded fields) — denylists break when new sensitive fields are added to the model. Apply the projection at the database query layer when possible: select only permitted columns in the SQL query so sensitive data never enters memory. This is more efficient and prevents sensitive fields from appearing in query logs or application memory dumps. OWASP API Security Top 10 (API3: Excessive Data Exposure) specifically identifies returning full objects and relying on the client to filter as a critical vulnerability. For nested objects, recurse the projection function to each sub-document. Test with snapshot tests — a snapshot for each role freezes the exact response shape and fails if a new field is accidentally added.

How do I implement resource-level access control (ownership checks) in a JSON API?

After verifying the role-level permission, enforce ownership in the SQL WHERE clause — not in application code after fetching: SELECT * FROM posts WHERE id = $1 AND (owner_id = $2 OR $3 = 'admin'). This is atomic, prevents TOCTOU races, and is more efficient than fetch-then-compare. For team-based access, join a membership table in the same WHERE clause. For hierarchical resources (comment belongs to post belongs to project), check the full chain in one query with JOINs. Return 404 (Not Found) instead of 403 (Forbidden) when a resource exists but the user cannot access it — 403 reveals the resource exists, enabling resource enumeration attacks where an attacker discovers private resource IDs by probing. Log all ownership check failures with the requesting user ID, resource ID, and action for incident forensics. For admin roles that bypass ownership checks, make the bypass explicit and auditable in the WHERE clause: OR $3 = 'admin'.

What is Casbin and how does it model JSON API access control policies?

Casbin is an open-source authorization library that separates the access control model (model.conf) from the policy data (policy.csv or a JSON/database adapter). The model defines what sections the policy has; the policy contains the actual rules. For RBAC: p lines define allow rules (p, editor, post, update); g lines define role inheritance (g, editor, viewer means editor inherits viewer permissions). Casbin evaluates enforce("alice", "post", "update") by resolving alice's roles through g lines, then checking if any matching p line exists. A JSON adapter stores the same policy in JSON format — preferable to CSV in production because it is programmatically writable (load from database, modify at runtime with enforcer.addPolicy()). Casbin supports RBAC, ABAC, ACL, and ReBAC — the model determines the semantics. Use Casbin when your rules are complex enough that hand-coded permission checks become unmaintainable (typically more than 5 roles or conditional permissions).

What is attribute-based access control (ABAC) and how does it differ from RBAC?

RBAC grants permissions based on the user's role — a static assignment. ABAC grants permissions based on attributes of the subject (user), resource, and environment — evaluated dynamically at request time. An ABAC rule might read: allow update if subject.role is editor AND resource.status is draft AND resource.ownerId equals subject.userId AND environment.hourUTC is between 9 and 17. This cannot be expressed in RBAC without creating a separate role for each combination. OPA (Open Policy Agent) is the most popular ABAC engine — you write Rego policies that evaluate a JSON input document and return a JSON decision: {"result": true}. The tradeoff: ABAC is more expressive but harder to audit — you cannot enumerate all resources a given user can access without evaluating every resource against the policy. RBAC is easier to reason about and audit but less expressive. In practice, most systems use RBAC as a baseline (role-level permission checks) augmented with resource-level ownership checks (a lightweight form of ABAC) and only adopt full OPA/ABAC for complex conditional policies.

What JWT claims should I use for JSON API authorization?

Standard registered claims for every token: sub (user ID), iss (auth server URL), aud (API identifier), exp (expiry — always set, typically 15 minutes), iat (issued at), jti (unique token ID for revocation). Custom authorization claims: role (single string for simple RBAC), roles (array for multi-role), orgId (tenant ID for multi-tenant APIs), permissions (explicit permission array — keep under 20 items to stay within the 4KB header/cookie limit). Never put PII (email, name, address), financial data, or health data in JWT claims — JWTs are base64-encoded, not encrypted, and are readable by any party that intercepts the token. For granular permissions beyond 20 items, store them in a database keyed by role and look them up on first request, caching in Redis by the jti claim. Use RS256 (asymmetric) rather than HS256 (symmetric) so each microservice verifies tokens with only the public key. Validate iss, aud, and exp on every request before trusting any custom claims.

How do I test that my JSON API access control is correct and complete?

Use three complementary approaches. Permission matrix testing: build a table of every (role, resource, action) triple and assert the expected allow/deny outcome with it.each() in Jest or Vitest. This catches missing rules and regressions when role definitions change — run it in CI on every PR that modifies RBAC policy. Property-based testing for privilege escalation: use fast-check to generate random (resource, action) pairs and assert that no lower-privileged role has a permission that a higher-privileged role lacks — this catches role hierarchy gaps. Snapshot testing of filtered responses: for each role, make a real API request and snapshot the response shape; when a new sensitive field is added to the data model, the snapshot test fails and forces explicit review. Negative tests: assert that missing tokens return 401, expired tokens return 401, viewer tokens on admin endpoints return 403, and cross-user resource access returns 404 (not 403). Run all tests against a real test database with seeded data — mocking the database layer hides bugs where the actual SQL query does not enforce ownership correctly.

Further reading and primary sources