Most APIs start with a simple boolean: authenticated or not. That works until you have more than one type of user and "admin can do everything" becomes a liability. Role-Based Access Control (RBAC) gives you fine-grained permissions without pulling in an external policy engine — and it's straightforward to implement directly in Go middleware.
This article walks through a practical RBAC layer: define roles and permissions, enforce them in HTTP middleware, and keep the code testable.
The data model
RBAC has three moving parts: users, roles, and permissions. A user is assigned one or more roles. Each role grants a set of permissions. A permission is a (resource, action) pair — ("posts", "write"), ("users", "delete").
package rbac
type Permission struct {
Resource string
Action string
}
type Role struct {
Name string
Permissions []Permission
}
// RoleRegistry maps role names to their permissions.
type RoleRegistry map[string]Role
func NewRegistry() RoleRegistry {
return RoleRegistry{
"viewer": {
Name: "viewer",
Permissions: []Permission{
{Resource: "posts", Action: "read"},
{Resource: "comments", Action: "read"},
},
},
"editor": {
Name: "editor",
Permissions: []Permission{
{Resource: "posts", Action: "read"},
{Resource: "posts", Action: "write"},
{Resource: "comments", Action: "read"},
{Resource: "comments", Action: "write"},
},
},
"admin": {
Name: "admin",
Permissions: []Permission{
{Resource: "posts", Action: "read"},
{Resource: "posts", Action: "write"},
{Resource: "posts", Action: "delete"},
{Resource: "users", Action: "read"},
{Resource: "users", Action: "write"},
{Resource: "users", Action: "delete"},
{Resource: "comments", Action: "read"},
{Resource: "comments", Action: "write"},
{Resource: "comments", Action: "delete"},
},
},
}
}
// Can reports whether a given role holds a specific permission.
func (r RoleRegistry) Can(roleName, resource, action string) bool {
role, ok := r[roleName]
if !ok {
return false
}
for _, p := range role.Permissions {
if p.Resource == resource && p.Action == action {
return true
}
}
return false
}
Keep the registry in memory for development. For anything beyond a handful of roles, load from a database — the shape stays identical; only the source changes.
JWT claims carry the role
The most common pattern is to embed the user's role in a JWT claim. After verifying the token signature, the middleware reads the claim and injects it into context.
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
// ClaimsKey is exported so tests can inject claims directly.
const ClaimsKey contextKey = "claims"
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func JWTAuth(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
// Always verify the signing method before trusting the key.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return secret, nil
})
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(ClaimsKey).(*Claims)
return c, ok
}
Two things worth calling out: always check the signing method before accepting the key — the !ok guard prevents algorithm confusion attacks. And never trust a claim value that didn't come out of a verified token.
The RBAC middleware
The permission check wraps any handler and requires a specific (resource, action) pair:
func RequirePermission(registry rbac.RoleRegistry, resource, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !registry.Can(claims.Role, resource, action) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
Wiring into routes:
registry := rbac.NewRegistry()
jwtMW := middleware.JWTAuth([]byte(os.Getenv("JWT_SECRET")))
mux := http.NewServeMux()
// Any authenticated user can read posts
mux.Handle("GET /posts", jwtMW(
middleware.RequirePermission(registry, "posts", "read")(postsHandler.List),
))
// Editors and admins can create
mux.Handle("POST /posts", jwtMW(
middleware.RequirePermission(registry, "posts", "write")(postsHandler.Create),
))
// Only admins can delete users
mux.Handle("DELETE /users/{id}", jwtMW(
middleware.RequirePermission(registry, "users", "delete")(usersHandler.Delete),
))
Each route states its requirement declaratively. No if user.IsAdmin() spread through handler logic.
Testing without a running server
Because RequirePermission wraps http.Handler, you can test it directly with httptest:
func TestRequirePermission(t *testing.T) {
registry := rbac.NewRegistry()
protected := middleware.RequirePermission(registry, "users", "delete")(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
cases := []struct {
role string
want int
}{
{"admin", http.StatusOK},
{"editor", http.StatusForbidden},
{"viewer", http.StatusForbidden},
{"", http.StatusUnauthorized}, // no claims in context
}
for _, tc := range cases {
t.Run(tc.role, func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/users/42", nil)
if tc.role != "" {
claims := &middleware.Claims{UserID: "u1", Role: tc.role}
ctx := context.WithValue(req.Context(), middleware.ClaimsKey, claims)
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("role=%q: got %d, want %d", tc.role, rec.Code, tc.want)
}
})
}
}
Testing registry.Can() separately from HTTP gives you pure unit tests that run in milliseconds and cover edge cases — unknown roles, empty role strings, roles added later.
When flat RBAC isn't enough
RBAC works well when permissions are static per role. It falls apart when decisions depend on resource state: "editors can only modify their own posts." That's Attribute-Based Access Control (ABAC), and it requires access to the resource being acted on, not just the user's role.
The practical approach: keep RBAC middleware as a first gate (is this role allowed to write posts at all?), then add an ownership check inside the handler where the resource is already loaded. Avoid putting ownership logic in middleware — it forces database calls before you even know whether the request body is valid.
For dynamic, policy-as-code scenarios — multi-tenant isolation, wildcard permissions, runtime policy updates — OPA with Rego is worth the operational cost. For most APIs, the flat model above handles the common cases without an external dependency.
If you're hardening a Go API beyond access control — TLS configuration, security headers, input validation — the free security hardening checklists on our site cover those layers in structured, actionable form.
The takeaway
RBAC in Go doesn't require a library. The core components are:
-
RoleRegistryholds permissions as(resource, action)pairs -
JWTAuthmiddleware verifies tokens and stores claims in context -
RequirePermissionreads claims and callsregistry.Can() - Routes declare their required permissions at the registration point
Keep the permission-check logic free of HTTP and database concerns so it stays unit-testable. Add a database-backed registry when roles need to change at runtime, and layer ABAC inside handlers only when ownership logic actually requires it.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.