|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/authorization
$cat docs/authorization.md
updated Recently·24 min read·published

Authorization

BackendSecurityIntermediate🎯Free Tools
Introduction

Authorization determines what an authenticated user is allowed to do. While authentication answers the question of who you are, authorization answers what you can access and which actions you can perform. A secure API must get both right.

Authorization models range from simple role-based checks to fine-grained attribute and policy engines. The right model depends on the complexity of your domain, the size of your user base, and compliance requirements.

This guide covers role-based access control, attribute-based access control, claims, permission models, ownership checks, and practical patterns for enforcing authorization in HTTP APIs.

Role-Based Access Control

Role-based access control, or RBAC, assigns permissions to roles and roles to users. It is the simplest authorization model and works well when your access rules align with job functions. For example, an admin can manage users, an editor can publish content, and a viewer can only read.

RBAC is easy to understand and audit. The main risk is role explosion: as requirements become more granular, teams create ever more specific roles. When that happens, it is time to add permissions or attributes.

rbac.ts
TypeScript
1type Role = "admin" | "editor" | "viewer";
2
3type Permission =
4 | "user:read"
5 | "user:create"
6 | "user:delete"
7 | "post:read"
8 | "post:create"
9 | "post:publish"
10 | "post:delete";
11
12const rolePermissions: Record<Role, Permission[]> = {
13 admin: ["user:read", "user:create", "user:delete", "post:read", "post:create", "post:publish", "post:delete"],
14 editor: ["post:read", "post:create", "post:publish"],
15 viewer: ["post:read"],
16};
17
18function hasPermission(user: User, permission: Permission): boolean {
19 return rolePermissions[user.role].includes(permission);
20}
21
22app.delete("/posts/:id", authenticate, (req, res) => {
23 if (!hasPermission(req.user, "post:delete")) {
24 return res.status(403).json({ error: "Forbidden" });
25 }
26 // proceed with deletion
27});

best practice

Check permissions, not roles, in application code. A role is a grouping concept; a permission is a concrete capability. Checking roles directly makes it harder to add new roles later.
Attribute-Based Access Control

Attribute-based access control, or ABAC, evaluates policies against attributes of the user, the resource, the action, and the environment. It is more expressive than RBAC and supports rules such as managers can approve expenses up to their department limit during business hours.

ABAC is powerful but harder to reason about. Policies can interact in subtle ways, and debugging access decisions becomes more complex. Start with RBAC and introduce ABAC when role-based rules are no longer sufficient.

abac.ts
TypeScript
1interface AccessContext {
2 subject: {
3 userId: string;
4 role: string;
5 department: string;
6 };
7 resource: {
8 ownerId: string;
9 department: string;
10 status: string;
11 };
12 action: "read" | "update" | "delete" | "approve";
13 environment: {
14 timeOfDay: number;
15 isBusinessHours: boolean;
16 };
17}
18
19function canApproveExpense(ctx: AccessContext): boolean {
20 return (
21 ctx.action === "approve" &&
22 ctx.subject.role === "manager" &&
23 ctx.subject.department === ctx.resource.department &&
24 ctx.environment.isBusinessHours &&
25 ctx.resource.status === "submitted"
26 );
27}

info

Store ABAC policies in a central policy engine such as Open Policy Agent or Cedar. Centralization makes it easier to audit decisions and update rules without redeploying application code.
Claims and Tokens

Claims are statements about a user carried by an identity token. Common claims include user ID, email, roles, and groups. When the token is signed, services can trust these claims without calling an identity provider on every request.

Keep claims small and stable. Tokens have size limits and are hard to revoke. Avoid putting rapidly changing permissions directly in a JWT. Instead, put stable identity claims in the token and look up permissions from a store or cache when needed.

jwt-claims.json
JSON
1{
2 "sub": "usr_123",
3 "email": "alice@example.com",
4 "iss": "forgelearn-auth",
5 "aud": "forgelearn-api",
6 "iat": 1690123456,
7 "exp": 1690124356,
8 "roles": ["editor"],
9 "groups": ["engineering", "on-call"],
10 "permissions": ["post:read", "post:create"]
11}

warning

Claims can be forged if the token signature is invalid or the signing key is compromised. Always verify signatures, issuers, audiences, and expiry before trusting claims.
Ownership Checks

Many authorization decisions reduce to ownership. A user should be able to update their own profile but not someone else's. A team member should be able to edit team resources. Ownership checks happen after authentication and before business logic.

Load the resource before checking ownership. Do not rely on the resource ID in the URL alone. A malicious user could pass a valid ID they do not own. Fetch the resource, compare the owner, and fail fast with a 403 status if the check fails.

ownership-check.ts
TypeScript
1app.patch("/posts/:id", authenticate, async (req, res, next) => {
2 try {
3 const post = await postRepository.findById(req.params.id);
4 if (!post) {
5 return res.status(404).json({ error: "Post not found" });
6 }
7
8 // Ownership check
9 if (post.authorId !== req.user.id && req.user.role !== "admin") {
10 return res.status(403).json({ error: "Forbidden" });
11 }
12
13 const updated = await postRepository.update(req.params.id, req.body);
14 return res.json({ data: updated });
15 } catch (err) {
16 next(err);
17 }
18});

best practice

Return 404 instead of 403 when the resource does not exist at all. Revealing that a resource exists but is forbidden leaks information. Only return 403 after confirming the resource exists and the user lacks access.
Policy Engines

For complex authorization, a dedicated policy engine separates access rules from application code. Open Policy Agent uses the Rego language to express policies. Cedar, developed by AWS, is optimized for authorization at scale. These engines accept a query and return allow or deny decisions.

opa-policy.rego
REGO
1package forgelearn.posts
2
3import future.keywords.if
4import future.keywords.in
5
6default allow := false
7
8allow if {
9 input.action == "read"
10 input.resource.status == "published"
11}
12
13allow if {
14 input.action in ["update", "delete"]
15 input.user.id == input.resource.authorId
16}
17
18allow if {
19 input.user.role == "admin"
20}
📝

note

Externalize authorization decisions when the same rules must be enforced across multiple services. A policy engine ensures consistent decisions and centralizes audit logging.
Permission Models

Permissions can be structured in several ways. Coarse-grained permissions such as post:write are simple but inflexible. Fine-grained permissions such as post:edit:published or resource-level ACLs are more powerful but harder to manage.

ModelGranularityComplexityBest For
RBACCoarseLowSmall teams, clear roles
ABACFineHighComplex enterprise rules
ACLPer-resourceMediumDocuments, files, shared resources
ReBACRelationship-basedHighSocial graphs, nested ownership

info

Start with RBAC and add resource-level checks for ownership. Introduce ABAC or ReBAC only when the product genuinely needs complex, data-driven access rules.
Enforcement Patterns

Enforce authorization as close to the operation as possible. Middleware is good for coarse checks such as requiring an admin role. Ownership and data-level checks usually belong in the service layer because they require loading the resource.

Never rely on client-side checks. A motivated user can bypass any UI restriction. All authorization decisions must be made on the server, using data the client cannot manipulate.

authorization-enforcement.ts
TypeScript
1// Middleware for coarse role checks
2function requireRole(...allowedRoles: string[]) {
3 return (req: Request, res: Response, next: NextFunction) => {
4 if (!allowedRoles.includes(req.user.role)) {
5 return res.status(403).json({ error: "Insufficient privileges" });
6 }
7 next();
8 };
9}
10
11app.get("/admin/users", authenticate, requireRole("admin"), listUsers);
12
13// Service-layer ownership check
14class PostService {
15 async update(user: User, postId: string, data: UpdatePostInput) {
16 const post = await this.repository.findById(postId);
17 if (!post) throw new NotFoundError();
18 if (post.authorId !== user.id && user.role !== "admin") {
19 throw new ForbiddenError("You cannot edit this post");
20 }
21 return this.repository.update(postId, data);
22 }
23}

danger

Authorization checks must also apply to list endpoints. Filter queries by the user's accessible scope. A list endpoint that returns every resource but relies on the UI to hide rows is a serious security vulnerability.
$Blueprint — Engineering Documentation·Section ID: BE-08·Revision: 1.0