Clean and Simple Role-Based Access Control in Go with Chi Router

Go Web Development

Implementing Role-Based Access Control in Go with Chi Router

When building Announcable, my web application for creating and managing release notes, I quickly realized I needed a way to separate user and invite management features from the rest of the application. After migrating the codebase to Go, I had the authentication system working well, but I needed a clean way to ensure that only specific users could access certain parts of the application.

This is where Role-Based Access Control (RBAC) comes in. If you’ve worked on any multi-user application, you’ve likely encountered the need to restrict certain features based on who’s using your app. Maybe editors can publish content but not delete user accounts, while administrators can do both. Perhaps premium users can access features that free users cannot.

In my previous blog posts, I shared my experience migrating Announcable to Go and building a custom authentication system. Today, I’ll walk you through the next logical step in that journey: implementing a clean, flexible RBAC system that integrates seamlessly with Chi router.

What makes this implementation particularly nice is its simplicity. There’s no complex configuration, no external dependencies, and no convoluted permission matrices—just a straightforward approach that’s easy to understand, maintain, and extend. And the best part? You can adapt it for your own Go applications with minimal effort.

Let’s dive in and see how we can secure our routes with just a few lines of code.

RBAC Fundamentals: A Brief Overview

Before diving into the code, let’s quickly establish what Role-Based Access Control actually is. At its core, RBAC is a security approach that restricts system access based on the roles users have within an organization or application.

The basic components of RBAC are:

  1. Roles: Categories of users with similar access needs (like „Admin“ or Manager“)
  2. Permissions: Specific actions that can be performed (such as „create_user“ or „delete_release_note“)
  3. Access Rights: The mapping between roles and the permissions they’re granted

What makes RBAC particularly powerful is its simplicity and scalability. Instead of managing permissions for each individual user (which becomes unwieldy as your user base grows), you assign users to roles and manage permissions at the role level.

For a web application like Announcable, this approach makes perfect sense. Can you imagine individually specifying which routes each user can access? That would be a maintenance nightmare! With RBAC, I can simply define that „Admins“ can access user management routes, while „Users“ cannot.

The implementation we’ll explore today follows this principle but keeps things extremely lightweight. Many RBAC systems can become complex with hierarchical roles, conditional permissions, and attribute-based controls. But for many applications, including mine, we can achieve robust security with a much simpler approach.

Our implementation will:

  • Define roles and permissions as simple enums
  • Create a straightforward mapping between roles and their permissions
  • Provide middleware that checks if a user’s role has the required permission
  • Integrate seamlessly with Chi router and our existing authentication system

This approach gives us the security benefits of RBAC without unnecessary complexity. And when your application needs grow, this foundation can be extended rather than replaced.

Now, let’s look at how to build this system in Go.

Building the RBAC Package

Now let’s get our hands dirty with some actual code. Our RBAC package needs to define roles, permissions, and the relationship between them. We’ll also create middleware that can be used to protect routes.

Defining Roles and Permissions

First, let’s create our role and permission types. In Go, we can use custom types with constants to create enum-like structures:

package rbac
 
type Role string
 
func (r Role) String() string {
	return string(r)
}
 
const (
	RoleAdmin   Role = "admin"
	RoleManager Role = "manager"
)
 
type Permission string
 
func (p Permission) String() string {
	return string(p)
}
 
const (
	PermissionManageAccess      Permission = "manage_access"
	PermissionManageReleaseNote Permission = "manage_release_note"
)

This approach gives us type safety while keeping things simple. Each role and permission is just a string constant, making comparisons efficient.

Mapping Roles to Permissions

Next, we need to define which permissions are granted to each role. We’ll use a map for this:

// rolePermissions maps roles to their granted permissions
var rolePermissions = map[Role][]Permission{
	RoleAdmin: {
		PermissionManageAccess,
		PermissionManageReleaseNote,
	},
	RoleManager: {
		PermissionManageReleaseNote,
	},
}
 
// HasPermission checks if a role has a specific permission
func (r Role) HasPermission(p Permission) bool {
    permissions, exists := rolePermissions[r]
    if !exists {
        return false
    }
 
    for _, permission := range permissions {
        if permission == p {
            return true
        }
    }
    return false
}

With this setup, we can easily check if a role has a particular permission.

Creating the Middleware

Now for the middleware that will protect our routes. This middleware will check if the user’s role (which should be added to the request by our authentication middleware) has the required permission:

// RequirePermission creates middleware that checks if the user has the required permission
func RequirePermission(permission Permission) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Get user role from context (set by auth middleware)
            userRole, ok := r.Context().Value(„userRole“).(Role)
            if !ok {
                http.Error(w, „Unauthorized“, http.StatusUnauthorized)
                return
            }
 
            // Check if the role has the required permission
            if !userRole.HasPermission(permission) {
                http.Error(w, „Forbidden“, http.StatusForbidden)
                return
            }
 
            // User has permission, proceed to the next handler
            next.ServeHTTP(w, r)
        })
    }
}

This middleware is designed to be used with Chi router. It returns a function that takes an HTTP handler and returns a new handler that performs the permission check before calling the original handler.

What I love about this approach is how clean and explicit it is. When you look at a route definition, you can immediately see what permission is required to access it. And if you need to add new roles or permissions, you simply update the constants and the permission map.

The entire package is small enough that you could fit it in a single file, yet powerful enough to secure your entire application. And because it’s just standard Go code with no external dependencies, it’s easy to understand and maintain.

Now, let’s see how to use this package with Chi router in our application.

Practical Implementation in Chi Router

Now that we have our RBAC package built, let’s see how to integrate it with Chi router and our existing authentication system. This is where the rubber meets the road—we’ll take our theoretical constructs and put them to practical use.

Integrating with Authentication

First, we need to ensure that our authentication middleware adds the user’s role to the request context. Here’s how this might look:

// AuthMiddleware authenticates the user and adds their info to the context
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Get token from request (e.g., from cookie or Authorization header)
        token := getTokenFromRequest(r)
 
        // Validate token and get user info
        user, err := validateToken(token)
        if err != nil {
            http.Error(w, „Unauthorized“, http.StatusUnauthorized)
            return
        }
 
        // Create a new context with the user role
        ctx := context.WithValue(r.Context(), „userRole“, user.Role)
 
        // Call the next handler with the updated context
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The key part here is adding the user’s role to the request context. Our RBAC middleware will later extract this role to check permissions.

Protecting Routes with RBAC Middleware

Now let’s see how to use our RBAC middleware to protect routes in a Chi router setup:

func SetupRoutes(r *chi.Mux) {
    // Apply authentication middleware to all routes
    r.Use(AuthMiddleware)
 
    // Public routes accessible to all authenticated users
    r.Get(„/release-notes“, handlers.ListReleaseNotes)
    r.Get(„/profile“, handlers.ViewProfile)
 
    // Routes that require specific permissions
    r.Group(func(r chi.Router) {
        // Only users with user management permission can access these routes
        r.Use(rbac.RequirePermission(rbac.PermissionUserManagement))
        r.Get(„/users“, handlers.ListUsers)
        r.Post(„/users“, handlers.CreateUser)
        r.Put(„/users/{id}“, handlers.UpdateUser)
        r.Delete(„/users/{id}“, handlers.DeleteUser)
    })
}

Chi’s router groups are perfect for this use case. We can apply our RBAC middleware to entire groups of routes, making the code both clean and explicit.

A Complete Example

Let’s put it all together with a complete example of how a request flows through the system:

  1. A user makes a request to /users to list all users
  2. The request first passes through the AuthMiddleware, which:
    • Validates the user’s authentication token
    • Determines the user’s role (e.g., RoleAdmin or RoleManager)
    • Adds the role to the request context
  3. The request then passes through the rbac.RequirePermission(rbac.PermissionUserManagement) middleware, which:
    • Extracts the user role from the context
    • Checks if that role has the PermissionUserManagement permission
    • If yes, allows the request to proceed; if no, returns a 403 Forbidden response
  4. If permitted, the request reaches the handlers.ListUsers function, which handles the actual business logic

This flow ensures that only users with the appropriate permissions can access sensitive routes. And because the permission check happens in middleware, our handler functions don’t need to worry about authorization at all — they can focus solely on their business logic.

What I particularly like about this approach is how it separates concerns:

  • Authentication (who are you?)
  • Authorization (what can you do?)
  • Business logic (how do we do it?)

Each layer has a single responsibility, making the code easier to understand, test, and maintain.

In Announcable, this pattern has worked extremely well. When I need to add a new feature, I simply decide which permission it requires and apply the appropriate middleware. The RBAC system takes care of the rest.

Conclusion

Implementing Role-Based Access Control in my Go application with Chi router has been a game-changer for Announcable. What started as a simple need to separate user and invite management from the rest of the application turned into an elegant, flexible security solution that I can build upon as the application grows.

The approach I’ve outlined in this post offers several key benefits:

Simplicity: The entire RBAC system is just a few dozen lines of code, with no external dependencies. It’s easy to understand, easy to modify, and easy to reason about.

Explicitness: When you look at the route definitions, it’s immediately clear what permissions are required for each endpoint. This self-documenting nature makes the codebase more maintainable.

Separation of Concerns: Authentication, authorization, and business logic are cleanly separated, allowing each component to focus on its specific responsibility.

Extensibility: Need to add new roles or permissions? Just update the constants and the permission map. The middleware doesn’t need to change at all.

What I find most satisfying about this implementation is how it leverages Go’s strengths — simple types, explicit error handling, and middleware patterns — to create a security system that feels natural to the language.

And do you know what’s particularly nice? This pattern isn’t specific to Announcable or even to Chi router. You can adapt this approach to any Go web application, regardless of the router or framework you’re using. The core concepts — roles, permissions, and middleware-based checks — remain the same.

If you’re building a Go web application that needs to restrict access based on user roles, I encourage you to try this approach. Start simple, with just the roles and permissions you need today, and expand as your application grows. You might be surprised at how far this straightforward pattern can take you.

Have you implemented RBAC in your Go applications? I’d love to hear about your approach and how it compares to what I’ve described here. Feel free to reach out and share your experiences!

Imprint

This website is created and run by

Daniel Benner

Zur Deutschen Einheit 2

81929 München

Germany

hello(a)danielbenner.de