LocalSignedUrlMiddleware
The LocalSignedUrlMiddleware validates HMAC-SHA256 signatures for LocalDisk temporary URLs. When you generate a temporaryUrl() on LocalDisk, the URL includes ?expires={timestamp}&signature={hex}. This middleware verifies those parameters before serving the file.
How It Works
1. Generate: disk.temporaryUrl('secret/report.pdf', 3600)
→ /files/secret/report.pdf?expires=1742500000&signature=a1b2c3d4...
2. Client requests that URL
3. Middleware:
├── Check: ?expires and ?signature params present → else 403
├── Check: expires > now → else 403 (link expired)
├── Reconstruct: HMAC-SHA256(signSecret, path + expires)
├── Compare: timing-safe compare → else 403 (invalid signature)
└── Pass: next() → serve the fileConfiguration
The middleware requires a signSecret in your LocalDisk configuration:
StorageModule.forRoot({
default: 'local',
disks: {
local: {
driver: 'local',
root: '/data/storage',
signSecret: 'your-secret-key-at-least-32-characters-long!!',
url: 'https://api.example.com/files',
},
},
});Minimum Secret Length
The signSecret must be at least 32 characters long. Shorter secrets will throw a StorageConfigurationError at startup. Use a cryptographically random string:
# Generate a secure secret
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Module Setup
Basic Setup
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import {
StorageModule,
LocalSignedUrlMiddleware,
} from '@fozooni/nestjs-storage';
@Module({
imports: [
StorageModule.forRoot({
default: 'local',
disks: {
local: {
driver: 'local',
root: '/data/storage',
signSecret: process.env.STORAGE_SIGN_SECRET,
url: 'https://api.example.com/files',
},
},
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/*');
}
}With Async Configuration
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
StorageModule,
LocalSignedUrlMiddleware,
} from '@fozooni/nestjs-storage';
@Module({
imports: [
ConfigModule.forRoot(),
StorageModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
default: 'local',
disks: {
local: {
driver: 'local',
root: config.get('STORAGE_ROOT', '/data/storage'),
signSecret: config.getOrThrow('STORAGE_SIGN_SECRET'),
url: config.get('STORAGE_URL', 'https://api.example.com/files'),
},
},
}),
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/*');
}
}Route Mounting Patterns
Serve All LocalDisk Files
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/*');
}Protect Only Specific Paths
configure(consumer: MiddlewareConsumer) {
// Only protect private files — public files served without signature
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/private/*', '/files/reports/*');
}With Static File Serving
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
// Serve static files from the local disk root
ServeStaticModule.forRoot({
rootPath: '/data/storage',
serveRoot: '/files',
}),
StorageModule.forRoot({
default: 'local',
disks: {
local: {
driver: 'local',
root: '/data/storage',
signSecret: process.env.STORAGE_SIGN_SECRET,
url: 'https://api.example.com/files',
},
},
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Middleware runs BEFORE static serving
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/*');
}
}Generating Signed URLs
Basic Temporary URL
import { Controller, Get, Param } from '@nestjs/common';
import { InjectDisk, FilesystemContract } from '@fozooni/nestjs-storage';
@Controller('documents')
export class DocumentController {
constructor(
@InjectDisk('local')
private readonly disk: FilesystemContract,
) {}
@Get(':id/download')
async getDownloadLink(@Param('id') id: string) {
const path = `documents/${id}.pdf`;
if (!(await this.disk.exists(path))) {
throw new Error('Document not found');
}
// Generate a URL valid for 1 hour (3600 seconds)
const url = await this.disk.temporaryUrl(path, 3600);
return { url, expiresIn: 3600 };
}
}Signed URL with Custom Expiration
@Get(':id/preview')
async getPreviewLink(@Param('id') id: string) {
const path = `documents/${id}.pdf`;
// Short-lived URL for preview (5 minutes)
const previewUrl = await this.disk.temporaryUrl(path, 300);
// Longer-lived URL for download (24 hours)
const downloadUrl = await this.disk.temporaryUrl(path, 86400);
return {
preview: { url: previewUrl, expiresIn: 300 },
download: { url: downloadUrl, expiresIn: 86400 },
};
}Complete Signed URL Flow
End-to-end example: generate, serve, and validate signed URLs.
1. Controller — Generate Signed URLs
import {
Controller,
Get,
Param,
NotFoundException,
} from '@nestjs/common';
import { InjectDisk, FilesystemContract } from '@fozooni/nestjs-storage';
@Controller('api/files')
export class FileController {
constructor(
@InjectDisk('local')
private readonly disk: FilesystemContract,
) {}
@Get(':path(*)/signed-url')
async getSignedUrl(@Param('path') path: string) {
if (!(await this.disk.exists(path))) {
throw new NotFoundException();
}
const url = await this.disk.temporaryUrl(path, 3600);
const metadata = await this.disk.getMetadata(path);
return {
url,
filename: path.split('/').pop(),
size: metadata.size,
mimetype: metadata.mimetype,
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
};
}
}2. Module — Wire Up Middleware
@Module({
imports: [StorageModule.forRoot(storageConfig)],
controllers: [FileController],
})
export class FileModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Protect the /files/* route where static files are served
consumer
.apply(LocalSignedUrlMiddleware)
.forRoutes('/files/*');
}
}3. Client — Use the Signed URL
// 1. Request a signed URL from your API
const response = await fetch('/api/files/reports/q1-2026.pdf/signed-url');
const { url } = await response.json();
// url: "https://api.example.com/files/reports/q1-2026.pdf?expires=1742500000&signature=a1b2c3..."
// 2. Access the file directly using the signed URL
window.open(url); // Browser navigates to the signed URL
// 3. Middleware validates the signature and serves the file
// If the signature is invalid or expired → 403 ForbiddenValidation Failure Responses
When the middleware rejects a request, it returns a 403 Forbidden JSON response:
Missing Parameters
{
"statusCode": 403,
"message": "Forbidden: missing signature or expiration",
"error": "Forbidden"
}Expired URL
{
"statusCode": 403,
"message": "Forbidden: URL has expired",
"error": "Forbidden"
}Invalid Signature
{
"statusCode": 403,
"message": "Forbidden: invalid signature",
"error": "Forbidden"
}Timing-Safe Comparison
The middleware uses crypto.timingSafeEqual() to compare signatures, preventing timing attacks that could be used to guess the secret. This is a security best practice for HMAC verification.
HTTPS in Production
Always use HTTPS in production when serving signed URLs. Over HTTP, the signature and expiration parameters are visible in plain text, and a man-in-the-middle could extract valid signed URLs.
Set the url configuration to your HTTPS endpoint:
{
driver: 'local',
root: '/data/storage',
signSecret: process.env.STORAGE_SIGN_SECRET,
url: 'https://api.example.com/files', // Always HTTPS in production
}Secret Rotation
If you need to rotate the signSecret, be aware that all existing signed URLs will immediately become invalid. To implement graceful rotation:
- Support verifying against both old and new secrets during a transition period
- Generate new URLs with the new secret
- Remove the old secret after all outstanding URLs have expired