This document outlines the current security architecture and implementation in the Harakka App.
- Authentication
- Authorization
- Middleware & Guards
- Role-Based Access Control
- API Security
- File Storage Security
- Input Validation
- CORS Configuration
- Session Management
- Database Security
- Error Handling
- Environment Variables
- Security Headers
- Audit Logging
The application uses Supabase Auth with custom JWT middleware for authentication, replacing the previous guard-based approach.
- Registration/Login: Through Supabase Auth
- JWT Token: Custom JWT service generates tokens
- Middleware: Auth.middleware.ts validates requests
- Multi-tenant: Organization-based user roles
Location: src/middleware/Auth.middleware.ts
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(
private readonly supabaseService: SupabaseService,
private readonly jwtService: JwtService,
) {}
async use(req: AuthRequest, res: Response, next: NextFunction) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("No valid authorization header");
}
const token = authHeader.split(" ")[1];
// Validate JWT token
const decoded = await this.jwtService.verifyToken(token);
// Get user data from Supabase
const supabase = this.supabaseService.createClient(token);
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
throw new UnauthorizedException("Invalid token");
}
// Attach user and supabase client to request
req.user = user;
req.supabase = supabase;
next();
} catch (error) {
throw new UnauthorizedException("Authentication failed");
}
}
}For detailed frontend authentication implementation, see Frontend API Integration. The frontend uses Supabase Auth hooks to manage authentication state and automatically includes JWT tokens in API requests.
// Basic frontend auth flow (simplified)
const { user, authLoading } = useAuth();
// API requests automatically include auth headers
const response = await apiClient.get("/items", {
headers: { Authorization: `Bearer ${token}` },
});The application now uses a 5-tier role hierarchy with multi-tenant organization support:
| Role | Priority | Description |
|---|---|---|
| user | 1 | Basic user access |
| requester | 2 | Can create bookings |
| storage_manager | 3 | Manage inventory |
| tenant_admin | 4 | Organization admin |
| super_admin | 5 | System-wide access |
-- Multi-tenant role system
CREATE TABLE roles (
id UUID PRIMARY KEY,
name VARCHAR NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE user_organization_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
organization_id UUID REFERENCES organizations(id),
role_id UUID REFERENCES roles(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraint: Only one active role per user per organization
CONSTRAINT unique_active_role_per_user_org
UNIQUE (user_id, organization_id) WHERE is_active = true
);Applied globally to all routes except public endpoints:
// In AppModule
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.exclude(
// Public endpoints
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/register", method: RequestMethod.POST },
{ path: "/", method: RequestMethod.GET }, // Health check
)
.forRoutes({ path: "*", method: RequestMethod.ALL });
}
}Location: src/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>("roles", [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true; // No role requirement
}
const request = context.switchToHttp().getRequest<AuthRequest>();
const user = request.user;
if (!user) {
return false;
}
// Get user's roles for current organization
const userRoles = await this.getUserRoles(user.id, request.organizationId);
return requiredRoles.some((role) => userRoles.includes(role));
}
}Location: src/guards/recaptcha.guard.ts
@Injectable()
export class RecaptchaGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const recaptchaToken = request.body?.recaptchaToken;
if (!recaptchaToken) {
throw new BadRequestException("ReCaptcha token required");
}
// Verify with Google ReCaptcha API
const isValid = await this.verifyRecaptcha(recaptchaToken);
if (!isValid) {
throw new UnauthorizedException("Invalid ReCaptcha");
}
return true;
}
}Location: src/decorators/roles.decorator.ts
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);// Example from storage-items.controller.ts
@Controller("items")
export class StorageItemsController {
// Public access - no authentication required
@Get()
async getAllItems() {
// Anyone can view items
}
// Authenticated users only - no specific role required
@Post()
@UseGuards(RolesGuard)
async createBookingRequest() {
// Any authenticated user can create booking requests
}
// Storage managers and above
@Post("create")
@UseGuards(RolesGuard)
@Roles("storage_manager", "tenant_admin", "super_admin")
async createItem() {
// Only storage managers and above can create items
}
// Tenant admins and above
@Delete(":id")
@UseGuards(RolesGuard)
@Roles("tenant_admin", "super_admin")
async deleteItem() {
// Only tenant admins and above can delete items
}
// Super admin only
@Get("admin/all-organizations")
@UseGuards(RolesGuard)
@Roles("super_admin")
async getAllOrganizationItems() {
// Only super admin can see items across all organizations
}
}// User management - tenant admin within organization
@Roles('tenant_admin')
async manageOrganizationUsers() {}
// Inventory management - storage manager and above
@Roles('storage_manager', 'tenant_admin', 'super_admin')
async manageInventory() {}
// System administration - super admin only
@Roles('super_admin')
async systemAdministration() {}
// Booking management - requester and above
@Roles('requester', 'storage_manager', 'tenant_admin', 'super_admin')
async manageBookings() {}All endpoints use validation DTOs to ensure data integrity:
// Example from booking/dto/create-booking.dto.ts
export class CreateBookingDto {
@IsDateString()
@IsNotEmpty()
start_date: string;
@IsDateString()
@IsNotEmpty()
end_date: string;
@IsEnum(["pickup", "delivery"])
pickup_method: "pickup" | "delivery";
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateBookingItemDto)
booking_items: CreateBookingItemDto[];
@IsOptional()
@IsString()
notes?: string;
}API endpoints implement rate limiting to prevent abuse:
// NestJS throttler configuration
@Module({
imports: [
ThrottlerModule.forRoot({
ttl: 60, // Time window in seconds
limit: 10, // Max number of requests in time window
}),
],
})
export class AppModule {}
// Apply throttling to controllers
@Controller("auth")
@UseGuards(ThrottlerGuard)
export class AuthController {
// Even more restrictive for login attempts
@Post("login")
@Throttle({ default: { limit: 5, ttl: 60 } })
async login(@Body() loginDto: LoginDto) {
// Login logic
}
}Location: src/modules/item-images/
@Controller("item-images")
export class ItemImagesController {
@Post("upload")
@UseGuards(RolesGuard)
@Roles("storage_manager", "tenant_admin", "super_admin")
@UseInterceptors(
FileInterceptor("file", {
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
},
fileFilter: (req, file, callback) => {
// Only allow specific image types
if (!file.mimetype.match(/^image\/(jpeg|jpg|png|webp)$/)) {
return callback(
new BadRequestException("Only JPG, PNG, WebP images allowed"),
false,
);
}
callback(null, true);
},
}),
)
async uploadImage(
@UploadedFile() file: Express.Multer.File,
@Body() uploadDto: UploadImageDto,
) {
// Additional server-side validation
const validation = await this.validateImage(file);
if (!validation.isValid) {
throw new BadRequestException(validation.error);
}
return this.itemImagesService.uploadImage(file, uploadDto);
}
}Location: src/utils/sanitize.util.ts
export function sanitizeString(input: string): string {
return input
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "") // Remove scripts
.replace(/[<>]/g, "") // Remove HTML brackets
.trim();
}
export function sanitizeObject(obj: any): any {
if (typeof obj === "string") {
return sanitizeString(obj);
}
if (Array.isArray(obj)) {
return obj.map(sanitizeObject);
}
if (obj && typeof obj === "object") {
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
}
return sanitized;
}
return obj;
}Location: src/utils/validateImage.util.ts
export interface ImageValidationResult {
isValid: boolean;
error?: string;
fileSize?: number;
dimensions?: { width: number; height: number };
}
export async function validateImage(
file: Express.Multer.File,
): Promise<ImageValidationResult> {
try {
// File size check
if (file.size > 5 * 1024 * 1024) {
return { isValid: false, error: "File size exceeds 5MB limit" };
}
// MIME type check
if (!file.mimetype.match(/^image\/(jpeg|jpg|png|webp)$/)) {
return { isValid: false, error: "Invalid file format" };
}
// Additional checks (dimensions, etc.)
// ... validation logic
return { isValid: true, fileSize: file.size };
} catch (error) {
return { isValid: false, error: "File validation failed" };
}
}The application implements secure CORS settings to restrict API access to authorized origins:
// NestJS main.ts setup
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Get CORS settings from environment
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
// Set up CORS with secure defaults
app.enableCors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) return callback(null, true);
// Check if origin is in allowed list
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Block unauthorized origins
callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
credentials: true, // Allow cookies/auth headers
maxAge: 86400, // Cache preflight for 24 hours
});
await app.listen(process.env.PORT || 3000);
}The application implements secure JWT token management with automatic refresh:
// JWT Service - Location: src/modules/jwt/jwt.service.ts
@Injectable()
export class JwtService {
async generateToken(payload: JwtPayload): Promise<TokenPair> {
const accessToken = this.jwtService.sign(payload, {
expiresIn: process.env.JWT_EXPIRATION || "15m",
secret: process.env.JWT_SECRET,
});
const refreshToken = this.jwtService.sign(
{ sub: payload.sub, type: "refresh" },
{
expiresIn: process.env.JWT_REFRESH_EXPIRATION || "7d",
secret: process.env.JWT_REFRESH_SECRET,
},
);
return { accessToken, refreshToken };
}
async verifyToken(token: string): Promise<JwtPayload> {
try {
return this.jwtService.verify(token, {
secret: process.env.JWT_SECRET,
});
} catch (error) {
throw new UnauthorizedException("Invalid or expired token");
}
}
async refreshToken(refreshToken: string): Promise<TokenPair> {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET,
});
if (payload.type !== "refresh") {
throw new UnauthorizedException("Invalid refresh token");
}
// Generate new token pair
return this.generateToken({
sub: payload.sub,
email: payload.email,
// ... other claims
});
} catch (error) {
throw new UnauthorizedException("Invalid refresh token");
}
}
}For detailed frontend token management, see Frontend API Integration. The frontend securely stores tokens and handles automatic refresh:
// Basic secure token handling (simplified)
export class AuthService {
private accessToken: string | null = null;
private refreshToken: string | null = null;
// Store tokens securely (httpOnly cookies preferred)
storeTokens(tokens: TokenPair) {
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
// Store refresh token in httpOnly cookie
document.cookie = `refreshToken=${tokens.refreshToken}; HttpOnly; Secure; SameSite=Strict`;
}
// Automatic token refresh
async getValidToken(): Promise<string> {
if (this.isTokenExpired(this.accessToken)) {
await this.refreshAccessToken();
}
return this.accessToken;
}
}The application implements comprehensive database security using Supabase's PostgreSQL features with Row-Level Security (RLS) policies enabled on all tables.
For detailed information about database security policies and RLS implementation, see Database RLS Policies.
Key security features:
- Row-Level Security (RLS) enabled on all tables
- Multi-tenant data isolation through organization-based policies
- Role-based data access with hierarchical permissions
- Audit logging for all data modifications
- Parameterized queries to prevent SQL injection
// Global exception filter - Location: src/filters/all-exceptions.filter.ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Determine status code
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Sanitize error message for production
const message = this.getErrorMessage(exception, status);
// Log error for debugging (server-side only)
this.logError(exception, request);
// Send sanitized response
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
});
}
private getErrorMessage(exception: unknown, status: number): string {
if (process.env.NODE_ENV === "production" && status === 500) {
return "Internal server error"; // Don't leak error details
}
if (exception instanceof HttpException) {
const response = exception.getResponse();
return typeof response === "string"
? response
: (response as any).message;
}
return "Unknown error occurred";
}
private logError(exception: unknown, request: Request): void {
// Log full error details server-side for debugging
console.error("Exception occurred:", {
url: request.url,
method: request.method,
error: exception,
user: (request as any).user?.id,
timestamp: new Date().toISOString(),
});
}
}# JWT Configuration
JWT_SECRET=your-super-secure-jwt-secret-256-bits-minimum
JWT_EXPIRATION=15m
JWT_REFRESH_SECRET=different-secure-refresh-secret-256-bits
JWT_REFRESH_EXPIRATION=7d
# Supabase Configuration
SUPABASE_PROJECT_ID=your-project-id
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SUPABASE_URL=https://your-project.supabase.co
# API Security
ALLOWED_ORIGINS=http://localhost:5180,https://yourdomain.com
CORS_CREDENTIALS=true
# ReCaptcha (Bot Protection)
RECAPTCHA_SECRET_KEY=your-recaptcha-secret
RECAPTCHA_SITE_KEY=your-recaptcha-site-key
# Rate Limiting
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100
# File Upload Security
MAX_FILE_SIZE=5242880 # 5MB in bytes
ALLOWED_FILE_TYPES=image/jpeg,image/jpg,image/png,image/webp
# Production Security
NODE_ENV=production
HTTPS_ONLY=true
SECURE_COOKIES=true// In main.ts
import helmet from "helmet";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "*.supabase.co"],
connectSrc: ["'self'", process.env.SUPABASE_URL],
fontSrc: ["'self'", "fonts.googleapis.com", "fonts.gstatic.com"],
},
},
crossOriginEmbedderPolicy: false, // Disable for Supabase compatibility
}),
);
await app.listen(process.env.PORT || 3000);
}Location: src/modules/logs_module/logs.service.ts
@Injectable()
export class LogsService {
constructor(private readonly supabaseService: SupabaseService) {}
async logSecurityEvent(
event:
| "login"
| "logout"
| "failed_auth"
| "role_change"
| "permission_denied",
userId?: string,
metadata?: Record<string, any>,
): Promise<void> {
const supabase = this.supabaseService.getServiceClient();
await supabase.from("audit_logs").insert({
event_type: "security",
event_action: event,
user_id: userId,
metadata: {
timestamp: new Date().toISOString(),
ip_address: metadata?.ip,
user_agent: metadata?.userAgent,
...metadata,
},
});
}
async logDataAccess(
resource: string,
action: "create" | "read" | "update" | "delete",
userId: string,
resourceId?: string,
): Promise<void> {
// Log data access for compliance
const supabase = this.supabaseService.getServiceClient();
await supabase.from("audit_logs").insert({
event_type: "data_access",
event_action: `${action}_${resource}`,
user_id: userId,
resource_id: resourceId,
metadata: {
timestamp: new Date().toISOString(),
},
});
}
}Database-level audit triggers automatically log all changes to critical tables. For implementation details, see the existing audit trigger functions in the database schema.
- Authentication: Custom JWT middleware with Supabase Auth
- Authorization: Multi-tier role-based access control
- Input Validation: DTOs with class-validator
- File Security: Type validation, size limits, sanitization
- Error Handling: Sanitized error responses in production
- Audit Logging: Comprehensive security event logging
- Headers: Helmet.js security headers
- CORS: Configurable origin restrictions
- RLS Policies: Handled in Supabase - see Database RLS Policies
- Rate Limiting: Configured per endpoint as needed
- Session Management: JWT-based with configurable expiration
- Bot Protection: ReCaptcha guard for sensitive endpoints
- Environment Separation: Different security levels for dev/prod
- Never expose service role keys in frontend code
- Always validate file uploads server-side
- Use parameterized queries to prevent SQL injection
- Log security events for compliance and monitoring
- Keep JWT secrets secure and rotate regularly
- Validate role hierarchy in multi-tenant contexts
While certain validations and checks are also implemented on the frontend for improved user experience (e.g. preventing invalid actions before they reach the server), the core security backbone is the backend and Supabase RLS policies. This ensures that even if frontend checks are bypassed, data integrity and access control remain enforced at the server and database level.
This security guide reflects the current implementation as of October 2025. For RLS policies and database-level security, refer to the Database RLS Policies documentation maintained separately.