Skip to content

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 file

Configuration ​

The middleware requires a signSecret in your LocalDisk configuration:

ts
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:

bash
# Generate a secure secret
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

Module Setup ​

Basic Setup ​

ts
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 ​

ts
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 ​

ts
configure(consumer: MiddlewareConsumer) {
  consumer
    .apply(LocalSignedUrlMiddleware)
    .forRoutes('/files/*');
}

Protect Only Specific Paths ​

ts
configure(consumer: MiddlewareConsumer) {
  // Only protect private files — public files served without signature
  consumer
    .apply(LocalSignedUrlMiddleware)
    .forRoutes('/files/private/*', '/files/reports/*');
}

With Static File Serving ​

ts
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 ​

ts
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 ​

ts
@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 ​

ts
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 ​

ts
@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 ​

ts
// 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 Forbidden

Validation Failure Responses ​

When the middleware rejects a request, it returns a 403 Forbidden JSON response:

Missing Parameters ​

json
{
  "statusCode": 403,
  "message": "Forbidden: missing signature or expiration",
  "error": "Forbidden"
}

Expired URL ​

json
{
  "statusCode": 403,
  "message": "Forbidden: URL has expired",
  "error": "Forbidden"
}

Invalid Signature ​

json
{
  "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:

ts
{
  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:

  1. Support verifying against both old and new secrets during a transition period
  2. Generate new URLs with the new secret
  3. Remove the old secret after all outstanding URLs have expired

Released under the MIT License.