Implementing RBAC (Role-Based Access Control) in a Go REST API

go dev.to

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
))
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. RoleRegistry holds permissions as (resource, action) pairs
  2. JWTAuth middleware verifies tokens and stores claims in context
  3. RequirePermission reads claims and calls registry.Can()
  4. 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.

Source: dev.to

arrow_back Back to Tutorials