RBAC in Go — From a Hand-Rolled Approach to Casbin
From simple role checks to policy-driven authorization — and knowing when each one is enough.
RBAC in Go — From a Hand-Rolled Approach to Casbin

From simple role checks to policy-driven authorization — and knowing when each one is enough.
Authentication answers one question: who are you? Part 1 through 3 solved that completely — we can hash passwords, issue JWTs, and protect routes behind middleware.
But there’s a second question that authentication never answers: what are you allowed to do?
A logged-in user is not automatically allowed to do everything. An editor shouldn’t delete other users’ posts. A viewer shouldn’t change billing settings. A support agent shouldn’t access raw database exports. This is authorization — and Role-Based Access Control (RBAC) is the most practical model for implementing it.
The idea is simple: instead of assigning permissions directly to individual users (which gets unmanageable fast), you assign permissions to roles, then assign roles to users. Change what the editor role can do, and every editor in your system is updated instantly.
In this part, we’ll build RBAC in two stages — a lightweight hand-rolled approach for simple apps, then Casbin for when your policy complexity outgrows what a few if statements can handle.
The Problem with Checking Roles Inline
Before reaching for a library, let’s understand what the naive approach looks like — and why it breaks down.
// ❌ The "just check the role" trap
func deleteUserHandler(w http.ResponseWriter, r *http.Request) {
claims := middleware.GetUserClaims(r)
if claims.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ... delete user
}
This works for two roles and five endpoints. Now imagine you have:
- 5 roles:
viewer,editor,moderator,manager,admin - 40 endpoints across 8 resource types
- Some roles that inherit permissions from others (
managercan do everythingeditorcan, plus more) - Business rules that change quarterly
Suddenly you have role checks scattered across 40 handlers, no single place to audit who can do what, and every policy change requires touching production code. This is the problem RBAC is designed to solve.
Stage 1: Hand-Rolled RBAC
For most apps — especially early-stage ones — a clean in-code permission map is all you need. No external library, no configuration files, fully type-safe.
Defining Roles and Permissions
// rbac/rbac.go
package rbac
// Role represents a named set of permissions.
type Role string
const (
RoleViewer Role = "viewer"
RoleEditor Role = "editor"
RoleModerator Role = "moderator"
RoleAdmin Role = "admin"
)
// Permission represents a discrete action on a resource.
// Convention: "resource:action"
type Permission string
const (
// Article permissions
PermArticleRead Permission = "article:read"
PermArticleCreate Permission = "article:create"
PermArticleUpdate Permission = "article:update"
PermArticleDelete Permission = "article:delete"
// User permissions
PermUserRead Permission = "user:read"
PermUserUpdate Permission = "user:update"
PermUserDelete Permission = "user:delete"
// Admin permissions
PermAdminPanel Permission = "admin:panel"
PermAdminAudit Permission = "admin:audit"
)
// rolePermissions is the single source of truth for what each role can do.
// To change a policy, you change this map - nothing else.
var rolePermissions = map[Role][]Permission{
RoleViewer: {
PermArticleRead,
PermUserRead,
},
RoleEditor: {
PermArticleRead,
PermArticleCreate,
PermArticleUpdate,
PermUserRead,
},
RoleModerator: {
PermArticleRead,
PermArticleCreate,
PermArticleUpdate,
PermArticleDelete, // moderators can delete articles, editors can't
PermUserRead,
},
RoleAdmin: {
PermArticleRead,
PermArticleCreate,
PermArticleUpdate,
PermArticleDelete,
PermUserRead,
PermUserUpdate,
PermUserDelete,
PermAdminPanel,
PermAdminAudit,
},
}
The Permission Checker
// HasPermission returns true if the given role is allowed to perform the action.
func HasPermission(role Role, permission Permission) bool {
perms, exists := rolePermissions[role]
if !exists {
return false
}
for _, p := range perms {
if p == permission {
return true
}
}
return false
}
// HasAnyPermission returns true if the role has at least one of the given permissions.
// Useful for OR-logic gates: "can read OR can update".
func HasAnyPermission(role Role, permissions ...Permission) bool {
for _, p := range permissions {
if HasPermission(role, p) {
return true
}
}
return false
}
// HasAllPermissions returns true if the role has every one of the given permissions.
// Useful for AND-logic gates: "must be able to both read AND write".
func HasAllPermissions(role Role, permissions ...Permission) bool {
for _, p := range permissions {
if !HasPermission(role, p) {
return false
}
}
return true
}
Role Inheritance — Without Duplication
The permission map above has a subtle problem: RoleAdmin lists every permission by hand. If you add a new PermArticlePublish, you have to add it to every role that should have it. That's error-prone.
A cleaner approach is explicit inheritance:
// roleInheritance defines which roles inherit all permissions from a parent role.
// admin inherits from moderator, moderator inherits from editor, etc.
var roleInheritance = map[Role]Role{
RoleModerator: RoleEditor,
RoleAdmin: RoleModerator,
}
// resolvePermissions returns the full set of permissions for a role,
// including all inherited permissions from the role chain.
func resolvePermissions(role Role) map[Permission]struct{} {
resolved := make(map[Permission]struct{})
current := role
for {
if perms, ok := rolePermissions[current]; ok {
for _, p := range perms {
resolved[p] = struct{}{}
}
}
parent, hasParent := roleInheritance[current]
if !hasParent {
break
}
current = parent
}
return resolved
}
// HasPermission (updated to support inheritance)
func HasPermission(role Role, permission Permission) bool {
resolved := resolvePermissions(role)
_, ok := resolved[permission]
return ok
}
With this, you only define what’s unique to each role — inherited permissions flow automatically up the chain.
Wiring RBAC into the JWT Claims
Remember the Claims struct from Part 2? We add Role to it:
// auth/token.go
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"` // ← add this
TokenType TokenType `json:"token_type"`
}
// Update GenerateTokenPair to accept a role
func GenerateTokenPair(userID, email, role string) (*TokenPair, error) {
// ... same as before, but pass role into Claims
accessClaims := Claims{
// ...
Role: role,
}
// ...
}
Building the Authorization Middleware
Now we can build a clean RequirePermission middleware that wraps the Part 3 auth middleware:
// middleware/rbac.go
package middleware
import (
"encoding/json"
"net/http"
"github.com/yourname/go-auth-series/rbac"
)
// RequirePermission returns a middleware that checks whether the authenticated user
// has the specified permission. Must be used after the Authenticate middleware.
func RequirePermission(permission rbac.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetUserClaims(r)
if claims == nil {
// Should not happen if Authenticate runs first, but be defensive
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
role := rbac.Role(claims.Role)
if !rbac.HasPermission(role, permission) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{
"error": "forbidden - insufficient permissions",
})
return
}
next.ServeHTTP(w, r)
})
}
}
Composing Auth + RBAC on Routes
The middleware stack is now composable. In Chi (from Part 3):
r.Route("/api", func(r chi.Router) {
r.Use(appMiddleware.Authenticate) // Step 1: who are you?
// Anyone authenticated can read articles
r.Get("/articles", listArticlesHandler)
r.Get("/articles/{id}", getArticleHandler)
// Only editors and above can create/update
r.With(appMiddleware.RequirePermission(rbac.PermArticleCreate)).
Post("/articles", createArticleHandler)
r.With(appMiddleware.RequirePermission(rbac.PermArticleUpdate)).
Put("/articles/{id}", updateArticleHandler)
// Only moderators and above can delete
r.With(appMiddleware.RequirePermission(rbac.PermArticleDelete)).
Delete("/articles/{id}", deleteArticleHandler)
// Admin-only section
r.Route("/admin", func(r chi.Router) {
r.Use(appMiddleware.RequirePermission(rbac.PermAdminPanel))
r.Get("/users", listUsersHandler)
r.Delete("/users/{id}", deleteUserHandler)
})
})
Every route’s authorization policy is now visible in one place. No scattered if claims.Role == "admin" checks buried in handlers.
Stage 2: Casbin — When Policies Outgrow Code
The hand-rolled approach is great until your requirements look like this:
- “Editors can update articles, but only articles they created”
- “Managers have editor permissions in their department, but viewer permissions elsewhere”
- “Certain permissions are temporarily revoked during maintenance windows”
- “The compliance team needs to audit who can do what, without reading source code”
This is where Casbin comes in. Casbin supports enforcing authorization based on various access control models — ACL, RBAC, ABAC, and RESTful path matching — with both allow and deny authorization support.
The key difference from the hand-rolled approach: policies live in a configuration file or database, not in source code. Non-engineers can audit and modify them without a deployment.
Install
go get github.com/casbin/casbin/v2
The Model File
In Casbin, the access control model is encapsulated in a configuration file. Create configs/rbac_model.conf:
# configs/rbac_model.conf
[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) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
Breaking this down:
r = sub, obj, act— every access request has a subject (who), object (what resource), and action (what they're doing)g = _, _— enables role inheritance (g= group/role assignments)keyMatch2— allows wildcard path matching like/articles/:idp.act == "*"— a policy with action*grants all actions on that resource
The Policy File
# configs/rbac_policy.csv
# Role → permission assignments
# Format: p, role, resource_pattern, action
p, viewer, /api/articles, GET
p, viewer, /api/articles/*, GET
p, editor, /api/articles, GET
p, editor, /api/articles, POST
p, editor, /api/articles/*, GET
p, editor, /api/articles/*, PUT
p, moderator, /api/articles/*, *
p, moderator, /api/users, GET
p, admin, /api/*, *
# User → role assignments
# Format: g, user_id, role
g, user_001, admin
g, user_002, editor
g, user_003, viewer
Initializing the Enforcer
// rbac/casbin.go
package rbac
import (
"fmt"
"sync"
"github.com/casbin/casbin/v2"
)
var (
enforcer *casbin.Enforcer
once sync.Once
)
// GetEnforcer returns a singleton Casbin enforcer.
// Uses sync.Once to ensure thread-safe initialization.
func GetEnforcer() (*casbin.Enforcer, error) {
var initErr error
once.Do(func() {
e, err := casbin.NewEnforcer("configs/rbac_model.conf", "configs/rbac_policy.csv")
if err != nil {
initErr = fmt.Errorf("failed to initialize casbin enforcer: %w", err)
return
}
e.EnableLog(false) // set true for debugging
enforcer = e
})
return enforcer, initErr
}
The Casbin Authorization Middleware
// middleware/casbin.go
package middleware
import (
"encoding/json"
"net/http"
"github.com/yourname/go-auth-series/rbac"
)
// CasbinAuthorize is middleware that uses Casbin to check whether
// the authenticated user's role can perform the HTTP request.
// It evaluates: can role X perform METHOD on PATH?
func CasbinAuthorize() func(http.Handler) http.Handler {
enforcer, err := rbac.GetEnforcer()
if err != nil {
panic("failed to load casbin enforcer: " + err.Error())
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetUserClaims(r)
if claims == nil {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
// subject = user's role, object = URL path, action = HTTP method
role := claims.Role
path := r.URL.Path
method := r.Method
allowed, err := enforcer.Enforce(role, path, method)
if err != nil {
http.Error(w, `{"error":"authorization check failed"}`, http.StatusInternalServerError)
return
}
if !allowed {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{
"error": "forbidden",
"role": role,
"path": path,
})
return
}
next.ServeHTTP(w, r)
})
}
}
Wiring Casbin into Routes
// main.go (Chi with Casbin)
r.Route("/api", func(r chi.Router) {
r.Use(appMiddleware.Authenticate) // Step 1: validate JWT, inject claims
r.Use(appMiddleware.CasbinAuthorize()) // Step 2: check policy
r.Get("/articles", listArticlesHandler)
r.Post("/articles", createArticleHandler)
r.Get("/articles/{id}", getArticleHandler)
r.Put("/articles/{id}", updateArticleHandler)
r.Delete("/articles/{id}", deleteArticleHandler)
r.Get("/users", listUsersHandler)
r.Delete("/users/{id}", deleteUserHandler)
})
Notice how clean this is. There are no per-route permission arguments — Casbin evaluates the policy automatically based on the request’s role, path, and HTTP method. Adding a new endpoint means adding a line to the CSV, not touching Go code.
Managing Policies at Runtime
One of Casbin’s most powerful features is the ability to modify policies without restarting your server:
// Grant a new permission to a role
enforcer.AddPolicy("editor", "/api/articles/*/publish", "POST")
// Assign a role to a user
enforcer.AddRoleForUser("user_004", "editor")
// Revoke a role
enforcer.DeleteRoleForUser("user_002", "editor")
// Check a user's roles
roles, _ := enforcer.GetRolesForUser("user_004")
// → ["editor"]
// Check which users have a specific role
users, _ := enforcer.GetUsersForRole("admin")
For production use, replace the file adapter with a database adapter so policy changes persist across restarts:
go get github.com/casbin/gorm-adapter/v3
import (
"github.com/casbin/casbin/v2"
gormadapter "github.com/casbin/gorm-adapter/v3"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
adapter, _ := gormadapter.NewAdapterByDB(db)
enforcer, _ := casbin.NewEnforcer("configs/rbac_model.conf", adapter)
Now every AddPolicy and AddRoleForUser call persists to the database automatically.
Hand-Rolled vs. Casbin: When to Use Which
Consideration Hand-Rolled Casbin Setup complexity Minimal — just Go code Requires model + policy files Policy visibility In source code In files or database Runtime policy changes Requires redeployment Supported out of the box Role inheritance Manual, explicit Built-in with g = _, _ Path-based rules Manual matching keyMatch2 wildcard support Audit by non-engineers Hard — must read code Easy — read the policy CSV When to use < 5 roles, stable policies Complex rules, evolving policies
The hand-rolled approach is not wrong for small applications — it’s often the right call. The mistake is keeping it when your policy complexity has already outgrown it.
Testing RBAC
// rbac/rbac_test.go
package rbac
import "testing"
func TestRolePermissions(t *testing.T) {
tests := []struct {
role Role
permission Permission
want bool
}{
// Viewer can read articles
{RoleViewer, PermArticleRead, true},
// Viewer cannot create articles
{RoleViewer, PermArticleCreate, false},
// Editor can create articles
{RoleEditor, PermArticleCreate, true},
// Editor cannot delete (moderator+ only)
{RoleEditor, PermArticleDelete, false},
// Moderator inherits editor permissions
{RoleModerator, PermArticleCreate, true},
// Moderator can also delete
{RoleModerator, PermArticleDelete, true},
// Admin can access admin panel
{RoleAdmin, PermAdminPanel, true},
// Moderator cannot access admin panel
{RoleModerator, PermAdminPanel, false},
// Unknown role has no permissions
{Role("ghost"), PermArticleRead, false},
}
for _, tt := range tests {
t.Run(string(tt.role)+"_"+string(tt.permission), func(t *testing.T) {
got := HasPermission(tt.role, tt.permission)
if got != tt.want {
t.Errorf("HasPermission(%s, %s) = %v, want %v",
tt.role, tt.permission, got, tt.want)
}
})
}
}
Common Mistakes to Avoid
1. Checking roles by string in handlers instead of using the permission layer
// ❌ WRONG — bypasses the permission model entirely
if claims.Role == "admin" || claims.Role == "moderator" {
// ...
}
// ✅ RIGHT - the permission map is the single source of truth
if !rbac.HasPermission(rbac.Role(claims.Role), rbac.PermArticleDelete) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
2. Storing sensitive permission logic in JWT claims
// ❌ WRONG — permissions in the token can be tampered with or become stale
type Claims struct {
Permissions []string `json:"permissions"` // grows token size, hard to revoke
}
// ✅ RIGHT - store only the role, resolve permissions server-side
type Claims struct {
Role string `json:"role"` // look up permissions from your map or Casbin
}
3. Using a single “super admin” bypass instead of modeling it properly
// ❌ WRONG — creates a hidden escape hatch that's hard to audit
if claims.Role == "super_admin" {
next.ServeHTTP(w, r)
return
}
// ... normal permission check
// ✅ RIGHT - model the admin role explicitly in your permission map or policy
// Casbin even has a built-in concept of superuser in the matcher:
m = g(r.sub, p.sub) || r.sub == "super_admin"
//But make it explicit and documented, not hidden.
4. Forgetting that Casbin’s file adapter doesn’t persist runtime changes
If you use AddPolicy() or AddRoleForUser() with the default file adapter, those changes are in-memory only. They vanish on restart. Use a database adapter in production.
Key Takeaways
- Authentication answers who are you; authorization answers what can you do — they are separate concerns that should be handled by separate layers.
- Hand-rolled RBAC with a permission map is the right starting point for most apps — minimal complexity, fully type-safe, easy to test.
- Add role inheritance to avoid duplicating permissions across roles; let each role define only what’s unique to it.
- Store only the role in JWT claims — resolve permissions server-side. Never store a permission list in the token.
- Casbin is the right upgrade path when: policies need runtime updates, non-engineers need to audit them, or you need path-based matching and role hierarchies beyond a few levels.
- Use a database adapter with Casbin in production — the file adapter does not persist runtime policy changes.
- Test your permission matrix exhaustively — a wrong
falseis a security hole, a wrongtrueis a privilege escalation bug.
That’s a Wrap
With Part 4, the core of a production-ready auth stack in Go is complete:
- **Part 1** — passwords stored safely with bcrypt, sessions managed with signed cookies
- **Part 2** — stateless authentication with JWT access and refresh tokens
- **Part 3** — a single middleware that protects routes across net/http, Chi, Gin, and Echo
- Part 4 — role-based authorization, from a simple permission map to policy-driven Casbin
These four parts give you everything you need to answer both fundamental questions every secure application must handle: who are you? and what are you allowed to do?
This article is part of the Go Auth Series:
- **Go Authentication from Scratch**
- **Go JWT From Scratch**
- **Go Auth Middleware**
- **Go RBAC And Authorziation **← you are here
메타데이터
- post_id
- c8f8b1cc4700
- slug
- rbac-in-go-from-a-hand-rolled-approach-to-casbin-c8f8b1cc4700
- url
- https://medium.com/@feildrixliemdra/rbac-in-go-from-a-hand-rolled-approach-to-casbin-c8f8b1cc4700
- canonical_url
- https://medium.com/@feildrixliemdra/rbac-in-go-from-a-hand-rolled-approach-to-casbin-c8f8b1cc4700
- author_url
- https://medium.com/@feildrixliemdra
- status
- ok
- fetched_at
- 2026-06-09 15:37:30