Dual Authentication with RBAC
Implement production-grade dual authentication combining session-based (stateful) and JWT-based (stateless) auth with comprehensive RBAC and multi-tenant isolation.
Core Principle: Different clients need different auth strategies. Web UIs benefit from sessions; APIs/mobile need stateless tokens. RBAC must work seamlessly across both.
See references/ for: schema.sql (complete database design with 9 tables)
When to Use
✅ Multi-tenant SaaS with web + API access ✅ Web UI + mobile apps authentication ✅ Role-based permissions with tenant isolation ✅ Token revocation capability required ✅ Multiple device sessions per user
❌ Simple single-tenant apps (overkill) ❌ Read-only public APIs ❌ Internal tools (simpler auth suffices)
Architecture
Web UI → Session Cookie + CSRF
Mobile → JWT Access + Refresh
API → JWT Access + Refresh
↓
Auth Layer → RBAC Engine → Database
Database Schema Essentials
Core tables (see references/schema.sql):
- •tbl_users - Accounts with tenant scope
- •tbl_global_roles - Reusable roles
- •tbl_permissions - Atomic permissions
- •tbl_global_role_permissions - Role → Permission
- •tbl_user_roles - User → Role (tenant-scoped)
- •tbl_user_permissions - Direct grants/denials
- •tbl_franchise_role_overrides - Tenant overrides
- •tbl_api_refresh_tokens - JWT revocation
- •tbl_login_attempts - Security monitoring
Key indexes: (tenant_id, user_id), (jti), (username, attempt_time)
Password Security
Algorithm: Argon2ID + Salt + Pepper
Hash Flow: 1. Random salt (16 bytes) 2. Combine password + salt 3. HMAC with pepper (env secret) 4. Argon2ID hash 5. Store: salt + hash Parameters: memory_cost: 65536 KB time_cost: 4 threads: 3 salt: 16 bytes pepper: env variable
Requirements: Min 8 chars, uppercase + lowercase + numbers + special
JWT Architecture
Access Token (15 min)
{
"sub": 12345, // user_id
"fid": 67, // franchise_id
"ut": "staff", // user_type
"did": "device-123", // device_id
"jti": "unique-id", // for revocation
"exp": 1706000900,
"type": "access"
}
Refresh Token (30 days)
{
"sub": 12345,
"fid": 67,
"ut": "staff",
"did": "device-123",
"jti": "unique-id",
"exp": 1708592000,
"type": "refresh"
}
Security:
- •HS256 signing (RS256 for distributed)
- •Timing-safe comparison
- •Unique JTI per token
- •Token rotation on refresh
- •Revocation table
Session Management
Config:
HttpOnly: true Secure: true SameSite: Strict Lifetime: 30 minutes
Session data:
{
user_id, franchise_id, user_type,
username, last_activity, csrf_token
}
Security:
- •Regenerate ID on login
- •Timeout check (30 min idle)
- •Complete destruction on logout
- •CSRF validation on mutations
RBAC Permission Resolution
Priority (highest to lowest):
1. User Denial → DENIES even if role grants 2. User Grant → GRANTS even if not in role 3. Franchise Override → Tenant enables/disables 4. Role Permission → Default from role 5. Super Admin → ALL permissions
Algorithm:
hasPermission(userId, franchiseId, permissionCode):
if user.type == 'super_admin': return true
if userPermission.denied(...): return false
if userPermission.granted(...): return true
for role in getUserRoles(userId, franchiseId):
override = getFranchiseOverride(...)
if override and override.disabled: continue
if permissionCode in getRolePermissions(role): return true
return false
Caching: 15 min TTL, invalidate on role/permission changes
Authentication Flows
Web Login (Session)
1. POST /auth/login {username, password, csrf_token}
2. Validate CSRF
3. Find user (check status, not locked)
4. Verify password
5. Create session (regenerate ID, store context)
6. Return redirect
API Login (JWT)
1. POST /api/v1/auth/login {username, password, device_id}
2. Find user, verify password
3. Generate access + refresh tokens
4. Persist refresh token (revocation table)
5. Return {access_token, refresh_token, expires_in}
Token Refresh
1. POST /api/v1/auth/refresh {refresh_token}
2. Verify token, check revocation
3. Revoke old token (rotation)
4. Generate new token pair
5. Persist new refresh token
6. Return new tokens
Multi-Tenant Isolation
Session-based:
franchiseId = session.franchise_id SELECT * FROM orders WHERE franchise_id = ?
JWT-based:
token = verifyAccessToken(bearerToken) franchiseId = token.fid SELECT * FROM orders WHERE franchise_id = ?
Cross-tenant protection:
if user.type != 'super_admin' and user.franchise_id != requestedFranchiseId:
throw ForbiddenError("Cross-tenant access denied")
Security Checklist
Password
- • Argon2ID (memory_cost ≥ 65536 KB)
- • Random salt (16+ bytes)
- • Application pepper (env var)
- • Complexity validation
- • Rehash check on login
JWT
- • Access ≤ 15 min
- • Refresh rotation
- • Unique JTI
- • Secure secret (32+ bytes)
- • Audience + issuer validation
- • Timing-safe comparison
Session
- • HttpOnly, Secure, SameSite
- • Regeneration on login
- • 30 min timeout
- • Complete destruction
- • CSRF validation
Account Protection
- • Lock after 5 failures
- • Login attempt logging
- • Rate limiting
- • Password reset limits
- • Force change on first login
Multi-Tenant
- • Tenant ID in session/token
- • Tenant filter on EVERY query
- • Tenant-scoped permissions
- • Cross-tenant validation
RBAC
- • Permission caching (15 min)
- • Super admin bypass
- • Protected resource checks
- • Franchise overrides
Middleware Pattern
Session (Web):
requireAuth():
if not session.user_id: redirect("/login")
if timeout: logout(), redirect("/login")
session.last_activity = now()
return session
requirePermission(code):
session = requireAuth()
if not hasPermission(...): return 403
return session
JWT (API):
authenticateJWT():
token = extractBearerToken()
if not token: return 401
payload = verifyAccessToken(token)
return {user_id, franchise_id, user_type}
requirePermission(code):
context = authenticateJWT()
if not hasPermission(...): return 403
return context
Environment Variables
JWT_SECRET=<openssl rand -hex 32> JWT_ACCESS_TTL=900 JWT_REFRESH_TTL=2592000 PASSWORD_PEPPER=<openssl rand -hex 32> SESSION_LIFETIME=1800 MAX_LOGIN_ATTEMPTS=5
Common Endpoints
Web: /auth/login, /auth/logout, /auth/password/reset
API: /api/v1/auth/login, /api/v1/auth/refresh, /api/v1/auth/logout
Language-Specific
PHP: password_hash(PASSWORD_ARGON2ID), session_start(), firebase/php-jwt
Node: argon2 package, express-session, jsonwebtoken
Python: argon2-cffi, Flask-Session/Django, PyJWT
Go: golang.org/x/crypto/argon2, gorilla/sessions, golang-jwt/jwt
Testing
- • Valid login returns session/tokens
- • Invalid password → 401
- • Locked account → 403
- • Token refresh works
- • Logout revokes
- • Super admin bypasses
- • Role permissions resolve
- • User overrides work
- • Tenant isolation enforced
- • Missing permission → 403
- • Expired tokens rejected
- • Revoked tokens rejected
- • Invalid signatures rejected
- • CSRF works
- • Session timeout enforced
Implementation Steps
- •Create database schema (references/schema.sql)
- •Implement password helper (Argon2ID)
- •Implement JWT service (sign, verify, refresh)
- •Implement session management
- •Implement permission service (RBAC + caching)
- •Create auth endpoints
- •Create middleware (validators, permission checks)
- •Add security (rate limiting, locking, CSRF)
- •Write tests
- •Configure environment
Remember: Security is not optional. Follow checklist completely.