API Security
This document outlines critical security patterns and best practices for developing secure APIs in the DeployStack Backend. Following these guidelines ensures consistent security behavior and prevents common vulnerabilities.
Overview
Security in API development requires careful consideration of the order in which validation and authorization occur. The DeployStack Backend uses Fastify's hook system to implement security controls, and understanding the proper hook usage is crucial for maintaining security.
The Critical Security Pattern: Authorization Before Validation
The Problem
A common security anti-pattern occurs when authorization checks happen after input validation. This can lead to:
- Information Disclosure: Unauthorized users receive validation errors instead of proper 403 Forbidden responses
- Inconsistent Error Responses: Some endpoints return 400 (validation errors) while others return 403 (authorization errors)
- Security Through Obscurity Violation: API structure and validation rules are leaked to unauthorized users
Real-World Example
Consider this test failure that led to the discovery of this pattern:
// Test expectation
expect(response.status).toBe(403); // Expected: Forbidden
// Actual result
expect(response.status).toBe(400); // Received: Bad Request (validation error)The test was sending invalid data to a protected endpoint, expecting a 403 Forbidden response. Instead, it received a 400 Bad Request because validation ran before authorization.
Fastify Hook Execution Order
Understanding Fastify's hook execution order is essential for proper security implementation:
1. onRequest ← Use for early authentication setup
2. preParsing ← Use for request preprocessing
3. preValidation ← ✅ USE FOR AUTHORIZATION
4. preHandler ← Use for post-validation processing
5. Route Handler ← Your business logicKey Security Principle
Authorization must happen in preValidation to ensure it runs before schema validation.
Correct Implementation Patterns
✅ Secure Pattern: preValidation for Authorization
import { requireGlobalAdmin } from '../../../middleware/roleMiddleware';
// Reusable Schema Constants
const REQUEST_SCHEMA = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, description: 'Name is required' },
value: { type: 'string', description: 'Value field' }
},
required: ['name', 'value'],
additionalProperties: false
} as const;
const SUCCESS_RESPONSE_SCHEMA = {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' }
},
required: ['success', 'message']
} as const;
const ERROR_RESPONSE_SCHEMA = {
type: 'object',
properties: {
success: { type: 'boolean', default: false },
error: { type: 'string' }
},
required: ['success', 'error']
} as const;
// TypeScript interfaces
interface RequestBody {
name: string;
value: string;
}
interface SuccessResponse {
success: boolean;
message: string;
}
interface ErrorResponse {
success: boolean;
error: string;
}
export default async function secureRoute(server: FastifyInstance) {
server.post('/protected-endpoint', {
preValidation: requireGlobalAdmin(), // ✅ CORRECT: Runs before validation
schema: {
tags: ['Protected'],
summary: 'Protected endpoint',
description: 'Requires admin permissions',
security: [{ cookieAuth: [] }],
// Fastify validation schema
body: REQUEST_SCHEMA,
// OpenAPI documentation (same schema, reused)
requestBody: {
required: true,
content: {
'application/json': {
schema: REQUEST_SCHEMA
}
}
},
response: {
200: {
...SUCCESS_RESPONSE_SCHEMA,
description: 'Success'
},
401: {
...ERROR_RESPONSE_SCHEMA,
description: 'Unauthorized'
},
403: {
...ERROR_RESPONSE_SCHEMA,
description: 'Forbidden'
},
400: {
...ERROR_RESPONSE_SCHEMA,
description: 'Bad Request'
}
}
}
}, async (request, reply) => {
// If we reach here, user is authorized AND input is validated
const validatedData = request.body as RequestBody;
// Your business logic here
const successResponse: SuccessResponse = {
success: true,
message: 'Operation completed successfully'
};
const jsonString = JSON.stringify(successResponse);
return reply.status(200).type('application/json').send(jsonString);
});
}❌ Insecure Pattern: preHandler for Authorization
export default async function insecureRoute(server: FastifyInstance) {
server.post('/protected-endpoint', {
schema: {
// Schema definition...
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
value: { type: 'string' }
},
required: ['name', 'value'],
additionalProperties: false
}
},
preHandler: requireGlobalAdmin(), // ❌ WRONG: Runs after validation
}, async (request, reply) => {
// This handler may never be reached if validation fails first
});
}Security Implications
With Incorrect Pattern (preHandler)
Request Flow:
1. Request received
2. Schema validation runs → Returns 400 if invalid
3. Authorization check (never reached if validation fails)
4. Handler executionResult: Unauthorized users get validation errors, leaking API structure.
With Correct Pattern (preValidation)
Request Flow:
1. Request received
2. Authorization check → Returns 401/403 if unauthorized
3. Schema validation (only for authorized users)
4. Handler executionResult: Unauthorized users always get proper 401/403 responses.
Authorization Middleware Usage
Available Middleware Functions
The DeployStack Backend provides several authorization middleware functions:
// Role-based authorization
requireGlobalAdmin() // Requires 'global_admin' role
requireRole('role_id') // Requires specific role
// Permission-based authorization
requirePermission('permission.name') // Requires specific permission
requireAnyPermission(['perm1', 'perm2']) // Requires any of the permissions
// Team-aware permission authorization
requireTeamPermission('permission.name') // Requires permission within team context
requireTeamPermission('permission.name', getTeamIdFn) // Custom team ID extraction
// Ownership-based authorization
requireOwnershipOrAdmin(getUserIdFromRequest) // User owns resource OR is admin
// Dual authentication (Cookie + OAuth2)
requireAuthenticationAny() // Accept either cookie or OAuth2 Bearer token
requireOAuthScope('scope.name') // Enforce OAuth2 scope requirements
// Satellite authentication (API key-based)
requireSatelliteAuth() // Validates satellite API keys using argon2
requireUserOrSatelliteAuth() // Accept either user auth or satellite API key
// Registration token authentication (specialized)
validateRegistrationToken() // Validates JWT registration tokens for satellite pairingDual Authentication Support
For endpoints that support both web users (cookies) and CLI users (OAuth2 Bearer tokens), use the dual authentication middleware:
import { requireAuthenticationAny, requireOAuthScope } from '../../middleware/oauthMiddleware';
export default async function dualAuthRoute(server: FastifyInstance) {
server.get('/dual-auth-endpoint', {
preValidation: [
requireAuthenticationAny(), // Accept either auth method
requireOAuthScope('your:scope') // Enforce OAuth2 scope
],
schema: {
security: [
{ cookieAuth: [] }, // Cookie authentication
{ bearerAuth: [] } // OAuth2 Bearer token
]
}
}, async (request, reply) => {
// Endpoint accessible via both authentication methods
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
const userId = request.user!.id;
});
}For detailed OAuth2 implementation, see the Backend OAuth Implementation Guide and Backend Security Policy.
Satellite Authentication
For endpoints that need to authenticate DeployStack Satellite instances, use the satellite authentication middleware. Satellites use API key-based authentication with argon2 hash verification.
import { requireSatelliteAuth, requireUserOrSatelliteAuth } from '../../middleware/satelliteAuthMiddleware';
export default async function satelliteRoute(server: FastifyInstance) {
// Satellite-only endpoint
server.post('/satellites/:satelliteId/heartbeat', {
preValidation: [requireSatelliteAuth()], // Only satellites can access
schema: {
security: [{ bearerAuth: [] }] // API key via Bearer token
}
}, async (request, reply) => {
// Access satellite context
const satellite = request.satellite!;
const satelliteId = satellite.id;
const satelliteType = satellite.satellite_type; // 'global' or 'team'
});
// Hybrid endpoint (users OR satellites)
server.get('/satellites/:satelliteId/status', {
preValidation: [requireUserOrSatelliteAuth()], // Accept either auth method
schema: {
security: [
{ cookieAuth: [] }, // User authentication
{ bearerAuth: [] } // Satellite API key
]
}
}, async (request, reply) => {
// Check authentication type
if (request.satellite) {
// Authenticated as satellite
const satelliteId = request.satellite.id;
} else if (request.user) {
// Authenticated as user
const userId = request.user.id;
}
});
}Satellite Authentication Flow
The satellite authentication middleware performs these steps:
- Bearer Token Extraction: Extracts API key from Authorization header
- Database Lookup: Retrieves all satellite records from database
- Hash Verification: Uses argon2.verify() to validate API key against stored hashes
- Context Setting: Sets satellite information on request object for route handlers
Satellite Context Object
When satellite authentication succeeds, the middleware sets request.satellite with:
interface SatelliteContext {
id: string; // Satellite unique identifier
name: string; // Human-readable satellite name
satellite_type: 'global' | 'team'; // Deployment type
team_id: string | null; // Associated team (null for global)
status: 'active' | 'inactive' | 'maintenance' | 'error'; // Current status
}Security Considerations
- API Key Storage: Satellite API keys are stored as argon2 hashes in the database
- Key Generation: 32-byte cryptographically secure random keys (base64url encoded)
- Key Rotation: New API key generated on each satellite registration
- Scope Isolation: Satellites can only access their own resources and endpoints
Registration Token Authentication
For satellite registration security, the system uses specialized JWT-based registration tokens that follow a different security model than regular user authentication.
Registration Token Middleware
The validateRegistrationToken() middleware (located in src/middleware/registrationTokenMiddleware.ts) provides secure satellite registration through:
- JWT Validation: Cryptographically signed tokens with HMAC-SHA256
- Single-Use Enforcement: Tokens consumed after successful registration
- Scope Validation: Global vs team token verification
- Security Event Logging: Failed attempts monitored and logged
Token Format and Usage
Registration tokens follow specific prefixes:
deploystack_satellite_global_for DeployStack-operated satellitesdeploystack_satellite_team_for customer-deployed team satellites
Tokens are passed via standard Authorization header: Bearer deploystack_satellite_*
Error Response Pattern
Unlike regular authentication errors, registration token failures provide specific instructions:
{
"success": false,
"error": "registration_token_required",
"message": "Registration token required in Authorization header",
"instructions": "Set Authorization: Bearer <registration_token> header"
}Usage Context
Registration token authentication is exclusively used for the /api/satellites/register endpoint. It should not be used for regular API endpoints, which use the standard authentication methods above.
Team-Aware Permission System
For endpoints that operate within team contexts (e.g., /teams/:teamId/resource), use the team-aware permission middleware:
import { requireTeamPermission } from '../../../middleware/roleMiddleware';
// Reusable Schema Constants
const CREATE_RESOURCE_SCHEMA = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, description: 'Name is required' },
description: { type: 'string', description: 'Optional description' }
},
required: ['name'],
additionalProperties: false
} as const;
const SUCCESS_RESPONSE_SCHEMA = {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' }
},
required: ['success', 'message']
} as const;
const ERROR_RESPONSE_SCHEMA = {
type: 'object',
properties: {
success: { type: 'boolean', default: false },
error: { type: 'string' }
},
required: ['success', 'error']
} as const;
// TypeScript interfaces
interface CreateResourceRequest {
name: string;
description?: string;
}
interface SuccessResponse {
success: boolean;
message: string;
}
interface ErrorResponse {
success: boolean;
error: string;
}
export default async function teamResourceRoute(server: FastifyInstance) {
server.post('/teams/:teamId/resources', {
preValidation: requireTeamPermission('resources.create'), // ✅ Team-aware authorization
schema: {
tags: ['Team Resources'],
summary: 'Create team resource',
description: 'Creates a resource within the specified team context',
security: [{ cookieAuth: [] }],
params: {
type: 'object',
properties: {
teamId: { type: 'string', minLength: 1 }
},
required: ['teamId'],
additionalProperties: false
},
body: CREATE_RESOURCE_SCHEMA,
requestBody: {
required: true,
content: {
'application/json': {
schema: CREATE_RESOURCE_SCHEMA
}
}
},
response: {
201: {
...SUCCESS_RESPONSE_SCHEMA,
description: 'Resource created successfully'
},
401: {
...ERROR_RESPONSE_SCHEMA,
description: 'Unauthorized'
},
403: {
...ERROR_RESPONSE_SCHEMA,
description: 'Forbidden - Not team member or insufficient permissions'
},
400: {
...ERROR_RESPONSE_SCHEMA,
description: 'Bad Request'
}
}
}
}, async (request, reply) => {
const { teamId } = request.params as { teamId: string };
const resourceData = request.body as CreateResourceRequest;
// User is guaranteed to be:
// 1. Authenticated
// 2. A member of the specified team
// 3. Have the 'resources.create' permission within that team
// 4. Input is validated
// Your business logic here
const successResponse: SuccessResponse = {
success: true,
message: `Resource "${resourceData.name}" created successfully`
};
const jsonString = JSON.stringify(successResponse);
return reply.status(201).type('application/json').send(jsonString);
});
}How Team-Aware Permissions Work
The requireTeamPermission() middleware performs these security checks in order:
- Authentication Check: Verifies user is logged in
- Team ID Extraction: Gets team ID from URL params (
:teamId) or custom function - Global Admin Bypass: Global admins can access any team's resources
- Team Membership: Verifies user belongs to the specified team
- Team Role Lookup: Gets user's role within that team (
team_adminorteam_user) - Permission Check: Verifies the team role has the required permission
Team Permission Security Model
// Global Admin - Can access any team's resources
if (userRole?.id === 'global_admin') {
// Check if global admin role has the permission
return globalPermissions.includes(permission);
}
// Team Member - Must be member with appropriate role
const teamMembership = await TeamService.getTeamMembership(teamId, userId);
const teamRole = teamMembership.role; // 'team_admin' or 'team_user'
// Check if team role has required permission
const rolePermissions = ROLE_DEFINITIONS[teamRole];
return rolePermissions.includes(permission);Error Responses for Team Permissions
Team-aware endpoints return specific error messages:
// 401 - Not authenticated
{
"success": false,
"error": "Authentication required"
}
// 403 - Not a team member
{
"success": false,
"error": "You are not a member of this team"
}
// 403 - Team member but insufficient permissions
{
"success": false,
"error": "Insufficient permissions for this team operation",
"required_permission": "resources.create",
"user_team_role": "team_user"
}
// 400 - Invalid team ID
{
"success": false,
"error": "Team ID is required"
}Permission-Based Authorization (Recommended)
Permission-based authorization is the preferred approach for most endpoints as it provides:
- Granular Control: Fine-grained access control per feature
- Scalability: Easy to add new permissions without role changes
- Flexibility: Users can have different permission combinations
- Maintainability: Clear separation between authentication and authorization
Current Permission Structure
The system includes these MCP-related permissions:
// MCP Categories (Admin-only operations)
'mcp.categories.view' // View category listings
'mcp.categories.create' // Create new categories
'mcp.categories.edit' // Modify existing categories
'mcp.categories.delete' // Remove categories
// MCP Servers (User-accessible operations)
'mcp.servers.read' // List and search servers (all authenticated users)
'mcp.servers.global.view' // View global server details (admin-only)
'mcp.servers.global.create' // Create global servers (admin-only)
'mcp.servers.global.edit' // Modify global servers (admin-only)
'mcp.servers.global.delete' // Delete global servers (admin-only)
// MCP Team Servers
'mcp.servers.team.view_all' // View all team servers (admin-only)
// MCP Versions
'mcp.versions.manage' // Manage server versions (admin-only)Permission Assignment by Role
// Global Admin - Full access to all MCP features
global_admin: [
'mcp.servers.read', // Basic server access
'mcp.servers.global.view', // Global server management
'mcp.servers.global.create',
'mcp.servers.global.edit',
'mcp.servers.global.delete',
'mcp.servers.team.view_all', // Cross-team visibility
'mcp.categories.view', // Category management
'mcp.categories.create',
'mcp.categories.edit',
'mcp.categories.delete',
'mcp.versions.manage' // Version management
]
// Global User - Basic server access only
global_user: [
'mcp.servers.read' // Can list and search servers
]
// Team Admin - Basic server access (team servers managed separately)
team_admin: [
'mcp.servers.read' // Can list and search servers
]
// Team User - Basic server access
team_user: [
'mcp.servers.read' // Can list and search servers
]Correct Usage Examples
// Global admin only
server.delete('/admin/users/:id', {
preValidation: requireGlobalAdmin(),
schema: { /* ... */ }
}, handler);
// Specific permission required
server.post('/settings/bulk', {
preValidation: requirePermission('settings.edit'),
schema: { /* ... */ }
}, handler);
// User can access own data OR admin can access any
server.get('/users/:id/profile', {
preValidation: requireOwnershipOrAdmin(getUserIdFromParams),
schema: { /* ... */ }
}, handler);Error Response Consistency
Proper Error Response Structure
All authorization errors should follow this structure:
// 401 Unauthorized (not authenticated)
{
success: false,
error: "Authentication required"
}
// 403 Forbidden (authenticated but insufficient permissions)
{
success: false,
error: "Insufficient permissions",
required_permission: "settings.edit" // Optional: what was required
}Response Status Code Guidelines
- 401 Unauthorized: User is not authenticated (no valid session)
- 403 Forbidden: User is authenticated but lacks required permissions
- 400 Bad Request: Input validation failed (only for authorized users)
Testing Security Properly
Test Authorization Before Validation
describe('Security Tests', () => {
it('should return 403 for unauthorized users regardless of input validity', async () => {
// Test with invalid data - should still get 403, not 400
const response = await request(server)
.post('/protected-endpoint')
.set('Cookie', unauthorizedUserCookie)
.send({ invalid: 'data' }); // Intentionally invalid
expect(response.status).toBe(403); // Should be 403, not 400
expect(response.body.error).toContain('permission');
});
it('should return 400 for authorized users with invalid input', async () => {
// Test with invalid data - authorized user should get validation error
const response = await request(server)
.post('/protected-endpoint')
.set('Cookie', authorizedUserCookie)
.send({ invalid: 'data' }); // Intentionally invalid
expect(response.status).toBe(400); // Now validation error is appropriate
expect(response.body.error).toContain('validation');
});
});Advanced Security Patterns
Multiple Authorization Checks
For complex authorization requirements:
// Multiple checks in sequence
server.post('/complex-endpoint', {
preValidation: [
requireAuthentication(), // Must be logged in
requireRole('team_member'), // Must have team role
requirePermission('data.write') // Must have write permission
],
schema: { /* ... */ }
}, handler);Conditional Authorization
// Different auth requirements based on request
async function conditionalAuth(request: FastifyRequest, reply: FastifyReply) {
const { action } = request.body as { action: string };
if (action === 'delete') {
return requireGlobalAdmin()(request, reply);
} else {
return requirePermission('data.edit')(request, reply);
}
}
server.post('/conditional-endpoint', {
preValidation: conditionalAuth,
schema: { /* ... */ }
}, handler);Security Checklist
Before deploying any protected endpoint, verify:
- Authorization uses
preValidation, notpreHandler - Unauthorized users get 401/403, never validation errors
- Tests verify proper status codes for unauthorized access
- Error responses don't leak sensitive information
- Schema validation only runs for authorized users
- Documentation reflects security requirements
Related Documentation
- API Documentation Generation - General API development patterns
- Authentication System - User authentication implementation
- Role-Based Access Control - Permission system details
API Pagination Guide
Complete guide to implementing pagination in DeployStack Backend APIs, including best practices, patterns, and examples.
API Documentation Generation
Complete guide to generating OpenAPI specifications, Swagger documentation, and API testing tools for DeployStack Backend development.