Authorization
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, 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.
| 1 | type Role = "admin" | "editor" | "viewer"; |
| 2 | |
| 3 | type Permission = |
| 4 | | "user:read" |
| 5 | | "user:create" |
| 6 | | "user:delete" |
| 7 | | "post:read" |
| 8 | | "post:create" |
| 9 | | "post:publish" |
| 10 | | "post:delete"; |
| 11 | |
| 12 | const 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 | |
| 18 | function hasPermission(user: User, permission: Permission): boolean { |
| 19 | return rolePermissions[user.role].includes(permission); |
| 20 | } |
| 21 | |
| 22 | app.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
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.
| 1 | interface 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 | |
| 19 | function 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
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.
| 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
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.
| 1 | app.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
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.
| 1 | package forgelearn.posts |
| 2 | |
| 3 | import future.keywords.if |
| 4 | import future.keywords.in |
| 5 | |
| 6 | default allow := false |
| 7 | |
| 8 | allow if { |
| 9 | input.action == "read" |
| 10 | input.resource.status == "published" |
| 11 | } |
| 12 | |
| 13 | allow if { |
| 14 | input.action in ["update", "delete"] |
| 15 | input.user.id == input.resource.authorId |
| 16 | } |
| 17 | |
| 18 | allow if { |
| 19 | input.user.role == "admin" |
| 20 | } |
note
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.
| Model | Granularity | Complexity | Best For |
|---|---|---|---|
| RBAC | Coarse | Low | Small teams, clear roles |
| ABAC | Fine | High | Complex enterprise rules |
| ACL | Per-resource | Medium | Documents, files, shared resources |
| ReBAC | Relationship-based | High | Social graphs, nested ownership |
info
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.
| 1 | // Middleware for coarse role checks |
| 2 | function 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 | |
| 11 | app.get("/admin/users", authenticate, requireRole("admin"), listUsers); |
| 12 | |
| 13 | // Service-layer ownership check |
| 14 | class 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