Get a quote

API Authentication and Role-Based Access Control for SaaS Backends in Production

JWT validation is not an authorization system: it is identity verification. The gap between authentication and authorization is where the most serious access control bugs live in multi-tenant SaaS. This is the RBAC architecture we build in production Go backends.

JWT validation is not an authorization system. It is an identity verification mechanism. A valid JWT tells the server who is making the request. It does not tell the server what that user is allowed to do. In a multi-tenant SaaS backend, the gap between authentication and authorization is where the most serious access control bugs live. This is the RBAC architecture we build in production Go SaaS backends.

Why token-only authorization fails in multi-tenant SaaS

The most common authorization shortcut: include the user's role as a string claim in the JWT, then check the role claim in the request handler. This approach has two fundamental failures.

First, role claims in the JWT become stale. A user whose role changes from admin to viewer after a role update still carries an admin JWT until it expires. In systems with 24-hour JWT lifetimes, the user retains admin access for up to 24 hours after demotion.

Second, role strings embedded in JWTs resist granular permission modeling. A monolithic admin role that grants all privileges becomes unmanageable as the system grows. Different clients need different administrative subsets: the accountant needs to read financial reports but not delete products; the store manager needs to manage products but not access payroll.

Role-Based Access Control with database-backed permissions solves both problems.

The RBAC data model

Store roles and permissions in PostgreSQL, not in hardcoded constants or JWT claims.

CREATE TABLE roles (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name      TEXT NOT NULL,
  UNIQUE(tenant_id, name)
);

CREATE TABLE permissions (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code        TEXT NOT NULL UNIQUE,
  description TEXT
);

CREATE TABLE role_permissions (
  role_id       UUID NOT NULL REFERENCES roles(id),
  permission_id UUID NOT NULL REFERENCES permissions(id),
  PRIMARY KEY(role_id, permission_id)
);

CREATE TABLE user_roles (
  user_id   UUID NOT NULL,
  role_id   UUID NOT NULL,
  tenant_id UUID NOT NULL,
  PRIMARY KEY(user_id, role_id, tenant_id)
);

Permission codes are string constants defined in the Go codebase (examples: orders:create, reports:read, products:delete, users:manage). Roles are named groups of permissions, defined per tenant. An Accountant role in tenant A might have a different permission set than an Accountant role in tenant B.

This model lets tenant administrators create custom roles through a UI without any code deployment.

Loading permissions efficiently

The JWT carries only user_id and tenant_id. After JWT validation, the middleware loads the user's effective permissions from Redis (with PostgreSQL as the source of truth on cache miss).

func (s *AuthService) LoadPermissions(ctx context.Context, userID, tenantID uuid.UUID) (*UserPermissions, error) {
    cacheKey := fmt.Sprintf("perms:%s:%s", tenantID, userID)

    if cached, err := s.redis.Get(ctx, cacheKey).Result(); err == nil {
        var perms UserPermissions
        if json.Unmarshal([]byte(cached), &perms) == nil {
            return &perms, nil
        }
    }

    rows, err := s.db.Query(ctx, `
        SELECT DISTINCT p.code
        FROM user_roles ur
        JOIN role_permissions rp ON ur.role_id = rp.role_id
        JOIN permissions p ON rp.permission_id = p.id
        WHERE ur.user_id = $1 AND ur.tenant_id = $2
    `, userID, tenantID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    perms := &UserPermissions{
        UserID:    userID,
        TenantID:  tenantID,
        PermCodes: make(map[string]bool),
    }
    for rows.Next() {
        var code string
        rows.Scan(&code)
        perms.PermCodes[code] = true
    }

    if data, err := json.Marshal(perms); err == nil {
        s.redis.SetEx(ctx, cacheKey, data, 5*time.Minute)
    }
    return perms, nil
}

The 5-minute cache TTL balances database load against permission freshness. For most SaaS workloads, a 5-minute window between a role change and its effect is acceptable.

Enforcing permissions in middleware

Inject the loaded permissions into the request context after the auth middleware resolves them. Route-level middleware checks for a specific permission code before passing the request to the handler.

func RequirePermission(code string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            perms, ok := PermissionsFromContext(r.Context())
            if !ok || !perms.Has(code) {
                http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Applied at route registration:

r.With(RequirePermission("products:delete")).Delete("/products/{id}", h.DeleteProduct)
r.With(RequirePermission("reports:read")).Get("/reports/revenue", h.GetRevenueReport)
r.With(RequirePermission("users:manage")).Post("/users", h.CreateUser)

Immediate permission revocation

When a tenant administrator removes a role from a user, the change must take effect within the current request cycle, not after the cache expires. Delete the permission cache entry on every role modification:

func (s *AuthService) UpdateUserRoles(ctx context.Context, userID, tenantID uuid.UUID, newRoles []uuid.UUID) error {
    err := s.db.WithTransaction(ctx, func(tx pgx.Tx) error {
        tx.Exec(ctx, "DELETE FROM user_roles WHERE user_id = $1 AND tenant_id = $2", userID, tenantID)
        for _, roleID := range newRoles {
            tx.Exec(ctx, "INSERT INTO user_roles VALUES ($1, $2, $3)", userID, roleID, tenantID)
        }
        return nil
    })
    if err == nil {
        s.redis.Del(ctx, fmt.Sprintf("perms:%s:%s", tenantID, userID))
    }
    return err
}

The Redis DELETE is called after the transaction commits. If the transaction fails, the cache is not invalidated and remains consistent with the database.

Common authorization bugs that open access control failures

Checking permissions only at the HTTP layer. If a service method can be invoked from a background job or an internal RPC call that bypasses HTTP middleware, the permission check is never enforced. Check permissions at the service layer as well as the HTTP layer for any operation that modifies data or reads sensitive information.

Using the tenant ID from the request body rather than from the authenticated context. A malicious client can submit a request body with a different tenant ID than their JWT contains. Always derive the tenant ID from the verified JWT context, never from client-controlled request parameters.


Key lessons from production

JWT is an identity mechanism, not an authorization system. Store roles and permissions in PostgreSQL. Cache the resolved permission set in Redis with a short TTL. Immediately evict cache entries when roles change. Check permissions at the service layer, not only at the HTTP routing layer. Log all permission denials and all role modifications to a structured audit log.


Need help securing your SaaS backend?

Voxire designs and implements authentication and authorization systems for SaaS backends across Lebanon and the MENA region. If you are building RBAC from scratch or auditing an existing access control system, we can help.

https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp