Access Control Patterns

Production engineering guide for access control patterns covering patterns, implementation strategies, and operational best practices.

Access control governs who can do what, when, and where in a system. It matters most during system growth, integration complexity, or when security and compliance demands tighten—especially when roles, teams, or services evolve independently. This page details how to design and implement robust access control patterns across distributed systems, using common tools and well-understood mechanisms.

Principal-Based Access Control with Claims

Define access based on a user’s identity and attributes, not just roles. A principal is any entity—user, service, device—that can be authenticated and authorized. Use a standardized token format such as JWT (JSON Web Token) to carry claims about the principal.

When a client authenticates via OAuth 2.0, the identity provider issues a JWT with at least sub (subject), iss (issuer), aud (audience), and exp (expiration). Add custom claims like role, department, team, and permissions.

{
  "sub": "1234567890",
  "name": "Alice Johnson",
  "email": "[email protected]",
  "role": "admin",
  "department": "engineering",
  "team": "backend",
  "permissions": [
    "read:users",
    "write:config",
    "delete:logs"
  ],
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "exp": 1672531200
}

The service validates the JWT signature using a public key or JWK set, then checks aud and exp. It extracts claims and uses them to make access decisions.

Common failure mode: aud mismatch. The service expects aud = "https://api.example.com", but the token has aud = "api.example.com"—a case-sensitive, trailing-slash discrepancy. The system accepts the token, but access is silently granted to the wrong API.

Sharp edge: permissions is often a flat list, but the actual logic may expect a hierarchical structure. A request to /api/v1/users/123 may require read:users or read:users:123. Use permissions as a set, but also derive granular permissions from role, department, and team.

Use a middleware layer to extract claims and enrich the request context. In Node.js:

const { verify } = require('jsonwebtoken');

async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    const decoded = await verify(token, process.env.JWT_PUBLIC_KEY);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
}

Role-Based Access Control with Dynamic Assignment

Assign permissions to roles, and assign users to roles. A role is a named collection of permissions. A user may belong to multiple roles.

Define roles in a database. For example:

CREATE TABLE roles (
  id SERIAL PRIMARY KEY,
  name VARCHAR(64) UNIQUE NOT NULL,
  description TEXT
);

CREATE TABLE user_roles (
  user_id INTEGER REFERENCES users(id),
  role_id INTEGER REFERENCES roles(id),
  assigned_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (user_id, role_id)
);

Each role has a set of permissions. Permissions may be hierarchical: read:users, write:users, delete:users.

To enforce access, a service checks whether the user’s roles include the required permission. For example, to create a user, the service requires write:users in the user’s roles.

SELECT r.name
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = 123
  AND r.name = 'admin';

Failure mode: Role assignment is batched, but not immediate. A user is assigned the editor role via a script, but the service caches roles for 30 minutes. During that time, the user cannot create a new post, even though the role is assigned.

Sharp edge: The permission write:users does not imply read:users. The system assumes all roles are self-contained, but a writer role should inherit read permissions. Use a permission hierarchy table:

CREATE TABLE permissions (
  name VARCHAR(64) PRIMARY KEY,
  description TEXT
);

CREATE TABLE role_permissions (
  role_id INTEGER REFERENCES roles(id),
  permission_name VARCHAR(64) REFERENCES permissions(name),
  PRIMARY KEY (role_id, permission_name)
);

Now, write:users can be a leaf node. The service checks if any role grants the required permission, and may recursively resolve inheritance.

Attribute-Based Access Control with Policy Evaluation

When access depends on multiple attributes—user, resource, environment, time, location—use attribute-based access control (ABAC). It evaluates policies defined as expressions over attributes.

Define a policy in a simple format:

{
  "id": "access-to-internal-api",
  "description": "Allow team members to access internal APIs during business hours",
  "conditions": [
    {
      "attribute": "user.team",
      "operator": "eq",
      "value": "engineering"
    },
    {
      "attribute": "resource.type",
      "operator": "in",
      "value": [
        "api",
        "service"
      ]
    },
    {
      "attribute": "time.hour",
      "operator": "between",
      "value": [9, 17]
    },
    {
      "attribute": "user.location",
      "operator": "in",
      "value": [
        "san-francisco",
        "new-york"
      ]
    }
  ],
  "effect": "allow"
}

Evaluate a request: the system collects attributes from the user, resource, and request context.

{
  "user": {
    "id": 123,
    "name": "Alice",
    "team": "engineering",
    "location": "san-francisco"
  },
  "resource": {
    "id": "api-123",
    "type": "api",
    "owner": "engineering"
  },
  "context": {
    "time": "2023-01-15T10:30:00Z",
    "ip": "192.168.1.100"
  }
}

The evaluator applies the policy conditions. If all conditions are satisfied, the effect is allow.

Failure mode: time.hour is extracted as an integer, but the policy expects time.hour as a float. A request at 10:30:00 has time.hour = 10.5, but the condition between(9, 17) evaluates 10.5 >= 9 AND 10.5 <= 17 — true, but a future policy may expect time.hour = 10. The system silently accepts 10.5, but a time.hour field from a different source may be 10.0.

Sharp edge: The between operator is inclusive, but the policy assumes exclusive upper bounds. A request at 17:00:00 is accepted, but the policy expects 17:00 to be the last minute. Use between(9, 17) with inclusive: true in the policy definition.

Use a lightweight policy engine. In Python:

def evaluate_policy(policy, request):
    for condition in policy["conditions"]:
        attr = condition["attribute"]
        value = get_attribute(request, attr)
        operator = condition["operator"]
        expected = condition["value"]

        if operator == "eq":
            if value != expected:
                return False
        elif operator == "in":
            if value not in expected:
                return False
        elif operator == "between":
            min_val, max_val = expected
            if not (min_val <= value <= max_val):
                return False
        else:
            raise ValueError(f"Unknown operator: {operator}")

    return policy["effect"] == "allow"

Policy-Driven Authorization with Multiple Policies

Combine multiple policies to determine access. A request may be evaluated against a list of policies, each with a priority and condition.

Policies are evaluated in order. The first policy with a matching condition wins.

[
  {
    "id": "internal-only",
    "priority": 100,
    "conditions": [
      {
        "attribute": "user.location",
        "operator": "eq",
        "value": "remote"
      }
    ],
    "effect": "deny"
  },
  {
    "id": "team-access",
    "priority": 50,
    "conditions": [
      {
        "attribute": "user.team",
        "operator": "eq",
        "value": "marketing"
      }
    ],
    "effect": "allow"
  },
  {
    "id": "global-read",
    "priority": 10,
    "conditions": [],
    "effect": "allow"
  }
]

Evaluate a request:

Failure mode: Policy evaluation stops at the first matching policy. A user from marketing is also in remote, so internal-only applies, but it is not evaluated. The user is denied access, even though the team-access policy would have granted it.

Sharp edge: The priority field is often confused with order or sequence. A policy with priority = 50 is not necessarily evaluated before one with priority = 100. Use a stable sort: higher priority first, and resolve ties by id.

Use priority as a tiebreaker:

SELECT *
FROM policies
ORDER BY priority DESC, id ASC;

Resource-Level Access Control with Scopes

Grant permissions at a granular level: per resource, not just per type.

A resource has an id, type, and owner. Permissions are scoped to a resource.

Define a resource_scopes table:

CREATE TABLE resource_scopes (
  resource_id INTEGER REFERENCES resources(id),
  scope VARCHAR(64) NOT NULL,
  granted_to INTEGER REFERENCES users(id),
  granted_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (resource_id, scope, granted_to)
);

A user may have read:posts:123, write:posts:123, and owner:posts:123.

To access /api/posts/123, the service checks:

Use a hierarchical lookup:

SELECT DISTINCT scope
FROM resource_scopes
WHERE resource_id = 123
  AND (scope = 'read:posts:123'
       OR scope LIKE 'read:posts:123%'
       OR scope = 'read:posts'
       OR scope LIKE 'read:posts%')
  AND user_id = 456;

Failure mode: scope LIKE 'read:posts:123%' matches read:posts:123 and read:posts:123:comments, but the service expects read:posts:123:comments to be a distinct permission. The query returns read:posts:123, but the user cannot edit comments.

Sharp edge: Scopes are case-sensitive. READ:POSTS:123 does not match read:posts:123. Use LOWER(scope) in the query or normalize scopes on insert.

Use a simple resolver:

function hasPermission(user, resourceId, requiredScope) {
  const scopes = [
    `${requiredScope}:${resourceId}`,
    requiredScope,
    `${requiredScope}:${resourceId}:*`,
    `${requiredScope}:*`
  ];

  const query = `
    SELECT 1
    FROM resource_scopes
    WHERE user_id = $1
      AND resource_id = $2
      AND scope IN (${scopes.map((_, i) => `$${i + 3}`).join(',')})
    LIMIT 1
  `;

  return db.query(query, [user.id, resourceId, ...scopes]);
}

Cross-Service Authorization with Delegation

When a service calls another service, it must delegate its access rights.

A service api.users calls api.logs to log a user action. The api.users service authenticates to api.logs using a service account. The api.logs service uses the service account’s roles and claims to determine access.

The api.users service includes a X-Forwarded-User header:

X-Forwarded-User: {"sub": "123", "team": "engineering"}

The api.logs service extracts the user from the header and enriches the request context. It checks whether the user has write:logs or write:logs:123.

Failure mode: The X-Forwarded-User header is not standardized. The service expects JSON, but the header is a stringified object. The api.logs service parses "{\"sub\": \"123\"}", but the sub field is user_id in the database. The system fails silently.

Sharp edge: The X-Forwarded-User header may be nested. A service calls another service, which calls a third. The third service receives a X-Forwarded-User header from the second service, but must merge it with the original user from the first service. Use a merge policy: the outer service’s user takes precedence, but inner users are preserved in a X-Forwarded-Users array.

{
  "users": [
    {
      "sub": "123",
      "team": "engineering"
    },
    {
      "sub": "456",
      "team": "support"
    }
  ]
}

The system uses the topmost user as the primary, and combines permissions from all.

Use X-Forwarded-User as the primary identity, but fall back to Authorization header if absent.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.