How to Implement RBAC (Role-Based Access Control) in Go

Implement RBAC in Go by manually mapping roles to permissions and checking them in your middleware or handlers.

The scattered check problem

You are building an internal dashboard. The CEO needs to see revenue charts. The intern should only see their own task list. You write a handler for the revenue page and add a quick check: if the user is an admin, show the data. A week later, you add a manager role. Managers need to see revenue too. You hunt down every handler that checks for admin and update it. You miss one endpoint. Now managers get a blank screen. Or worse, you forget to add a check to a new delete endpoint, and interns can wipe the database.

Role-Based Access Control stops you from scattering permission logic across your codebase. It centralizes the rules so you change them in one place. The handler code does not need to know who the user is. It only needs to ask whether the user's role allows the requested action.

Think of a bouncer with a laminated list at a club door. The guest shows a badge. The bouncer checks the list. If the badge matches the requirement for that door, the guest enters. The bouncer does not care about the guest's name or how they got there. The bouncer only cares about the badge and the list. Your code should work the same way. The HTTP handler cares about the permission. The RBAC layer cares about the role and the permission mapping.

RBAC is a lookup. Keep the map central.

Minimal implementation

Here is the data structure. Roles are strongly typed strings. Permissions are a map. This keeps configuration separate from business logic.

// Role defines the user categories in the system.
type Role string

const (
	// Admin has full access to all resources.
	Admin Role = "admin"
	// User has limited access to basic resources.
	User Role = "user"
)

// permissions maps each role to its allowed actions.
// Keeping this unexported prevents other packages from modifying it.
var permissions = map[Role][]string{
	Admin: {"read", "write", "delete"},
	User:  {"read"},
}

Here is the check function and a main function to demonstrate usage.

// HasPermission checks if a role allows a specific action.
func HasPermission(role Role, action string) bool {
	// Look up the allowed actions for this role.
	allowed, exists := permissions[role]
	if !exists {
		// Default deny: unknown roles get nothing.
		return false
	}
	// Iterate to find a matching action.
	for _, p := range allowed {
		if p == action {
			return true
		}
	}
	return false
}

func main() {
	// Test the permission check against the map.
	fmt.Println(HasPermission(Admin, "write")) // true
	fmt.Println(HasPermission(User, "write"))  // false
}

How the lookup runs

The permissions map is the source of truth. Keys are roles. Values are slices of strings. When HasPermission runs, it looks up the role. If the role is not in the map, exists is false. The function returns false immediately. This is default deny. If the role exists, the function loops through the slice. It compares the requested action against every allowed action. A match returns true. The loop finishes without a match, and the function returns false.

At runtime, Go stores the map in memory. The lookup is fast for small datasets. The slice iteration is linear. You trade a tiny bit of CPU time for a configuration that is easy to read and modify. Default deny protects you. Unknown roles get nothing.

Realistic middleware

In a real application, you wrap handlers so you do not repeat checks. Here is a middleware factory that enforces permissions before the handler runs.

// RequirePermission creates a middleware that checks a specific action.
func RequirePermission(action string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Retrieve the user from the request context.
			u, ok := r.Context().Value(KeyUser).(User)
			if !ok {
				// Missing user means authentication failed earlier.
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}
			// Deny access if the role lacks the permission.
			if !HasPermission(u.Role, action) {
				http.Error(w, "Forbidden", http.StatusForbidden)
				return
			}
			// Pass control to the next handler in the chain.
			next.ServeHTTP(w, r)
		})
	}
}

The middleware extracts the user from the context. context.Context always goes as the first parameter in Go functions, and request-scoped data lives inside it. The middleware checks r.Context().Value. It returns Unauthorized if there is no user. It returns Forbidden if the user lacks permission. Otherwise, it calls next.ServeHTTP.

You use this by wrapping your handlers during router setup.

// Wrap the revenue handler with the permission check.
http.Handle("/admin/revenue", RequirePermission("read_revenue")(revenueHandler))

The handler revenueHandler does not check permissions. It assumes the middleware did the work. This keeps handlers focused on business logic. Middleware enforces rules. Handlers focus on business logic.

Performance and structure

Scanning a slice is O(N). If you have a few permissions, it is fast enough. If you have hundreds, the loop adds up. You can switch to a map of booleans for O(1) lookups.

// fastPermissions uses a map of booleans for instant lookups.
var fastPermissions = map[Role]map[string]bool{
	Admin: {"read": true, "write": true, "delete": true},
	User:  {"read": true},
}

// HasPermissionFast checks access in constant time.
func HasPermissionFast(role Role, action string) bool {
	// Check if the role exists.
	actions, exists := fastPermissions[role]
	if !exists {
		return false
	}
	// Check if the action is allowed.
	return actions[action]
}

The inner map maps actions to true. The lookup actions[action] returns true if the key exists, or false if it does not. No loop needed. Use this when performance matters.

Run gofmt. It formats the map literals consistently. Do not argue about indentation; let the tool decide. Most editors run it on save.

Also, keep the permission map unexported. The variable permissions starts with a lowercase letter. Public names start with a capital letter. Private start lowercase. This encapsulates the data. Other packages cannot modify the map directly. They must use the check function.

Encapsulation prevents accidental tampering. Keep the map private.

Pitfalls and panics

The type assertion r.Context().Value(KeyUser).(User) returns two values. The first is the value. The second is a boolean. If you drop the boolean and the value is the wrong type, the program panics with panic: interface conversion: interface {} is nil, not main.User. Always use the comma-ok idiom.

Another pitfall is hardcoding roles in handlers. If you write if user.Role == Admin, you bypass the permission map. That defeats the purpose of RBAC. The map should be the single source of truth. If you hardcode roles, you will miss updates when permissions change.

Be careful with string pointers. Do not pass a *string for roles. Strings are already cheap to pass by value. Passing a pointer adds allocation overhead and risks nil dereferences. Use the Role type or plain strings.

Type assertions panic. Always use the comma-ok idiom.

When to use RBAC

Use a simple map lookup when roles and permissions are static and fit in memory. Use a database-backed check when permissions change frequently at runtime without restarting the server. Use a middleware wrapper when you need to enforce permissions across many HTTP handlers. Use a dedicated authorization library like Open Policy Agent when your policy logic involves complex rules beyond role checks. Use inline checks only for trivial scripts where a full RBAC system adds unnecessary overhead.

Pick the tool that matches your complexity.

Where to go next