Back to blog

Build a Video Platform: Secure Video Streaming

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Secure Video Streaming

Your HLS files are on disk, neatly organized in directories. But right now, anyone who guesses the path can download every segment directly. There's no authentication, no expiration, no protection at all. That's a problem if your content is behind a paywall.

In this post, we'll set up Nginx to serve HLS streams with secure_link — a module that validates a hash + expiration on every request. Spring Boot generates signed URLs, and only subscribers with valid tokens can watch. No valid token? Nginx returns a 403 before the segment ever leaves the server.

Time commitment: 2–3 hours
Prerequisites: Phase 6: Video Transcoding Pipeline

What we'll build in this post:
✅ Nginx configuration for serving HLS segments
secure_link module for token-based URL validation
✅ Signed URL generation in Spring Boot
✅ CORS headers for cross-origin video playback
✅ Download prevention strategies
✅ Playlist URL rewriting for signed streaming
✅ Complete request flow from auth check to video playback


The Problem: Unprotected Video Files

Without protection, your videos are just static files on a web server:

https://yoursite.com/videos/hls/1/1/master.m3u8     → Anyone can access
https://yoursite.com/videos/hls/1/1/720p/segment-000.ts  → Anyone can download

Someone could:

  • Share the direct URL and bypass your subscription
  • Write a script to download all segments and reconstruct the video
  • Hotlink your videos from their own site, using your bandwidth

We need a way to validate that every request for a video segment comes from an authorized user, and that the URL hasn't been shared or expired.


Architecture Overview

Here's how secure streaming works:

The key insight: Nginx validates every request — not just the playlist, but every single .ts segment. The token is tied to a base path (like /videos/hls/1/1/), so it covers the master playlist and all quality levels and segments under that path.


Nginx Configuration

Basic HLS Serving

First, let's set up Nginx to serve HLS files from the video storage directory:

# nginx/conf.d/hls.conf
 
# Rate limiting zone — prevent abuse
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
 
server {
    listen 80;
    server_name localhost;
 
    # HLS video streaming with secure_link
    location /videos/hls/ {
        # Secure link validation
        secure_link $arg_token,$arg_expires;
        secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
 
        # If secure_link is empty — invalid hash
        if ($secure_link = "") {
            return 403;
        }
 
        # If secure_link is "0" — link expired
        if ($secure_link = "0") {
            return 410;  # Gone
        }
 
        # Serve from video storage directory
        alias /data/videos/hls/;
 
        # Rate limiting
        limit_req zone=hls_limit burst=50 nodelay;
 
        # CORS headers
        add_header Access-Control-Allow-Origin $http_origin always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Range, Authorization" always;
        add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
 
        # Cache control for segments
        location ~* \.m3u8$ {
            add_header Cache-Control "no-cache, no-store, must-revalidate";
            add_header Access-Control-Allow-Origin $http_origin always;
            add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
            types {
                application/vnd.apple.mpegurl m3u8;
            }
        }
 
        location ~* \.ts$ {
            add_header Cache-Control "public, max-age=86400";
            add_header Access-Control-Allow-Origin $http_origin always;
            add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
            types {
                video/mp2t ts;
            }
        }
 
        # Prevent downloading — no Content-Disposition header
        add_header Content-Disposition "";
 
        # Disable directory listing
        autoindex off;
    }
 
    # Handle CORS preflight requests
    location /videos/hls/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $http_origin;
            add_header Access-Control-Allow-Methods "GET, OPTIONS";
            add_header Access-Control-Allow-Headers "Range, Authorization";
            add_header Access-Control-Max-Age 86400;
            add_header Content-Length 0;
            return 204;
        }
    }
 
    # Block direct access to raw uploads
    location /videos/raw/ {
        deny all;
        return 403;
    }
}

The secure_link module is built into Nginx (you may need to compile with --with-http_secure_link_module, but most distributions include it). Here's the flow:

The secure_link_md5 directive defines how the hash is computed:

secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";

This means: MD5(expiration_timestamp + request_URI + client_IP + secret_key). The client must provide the exact same hash in the token query parameter. Since only your Spring Boot server knows the secret key, only it can generate valid tokens.

Important: $remote_addr ties the token to the client's IP address. This prevents token sharing — a URL signed for one user won't work from another IP. If your users are behind a corporate proxy or VPN where IPs change, you can remove $remote_addr from the hash and rely on expiration alone.


Signed URL Generation in Spring Boot

Configuration

// src/main/java/com/videoplatform/api/config/StreamingConfig.java
package com.videoplatform.api.config;
 
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
 
@Configuration
@ConfigurationProperties(prefix = "streaming")
public class StreamingConfig {
 
    private String secretKey = "change-me-in-production";
    private int urlExpirationSeconds = 7200;  // 2 hours
    private String baseUrl = "http://localhost/videos/hls";
 
    // Getters and setters
    public String getSecretKey() { return secretKey; }
    public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
 
    public int getUrlExpirationSeconds() { return urlExpirationSeconds; }
    public void setUrlExpirationSeconds(int urlExpirationSeconds) {
        this.urlExpirationSeconds = urlExpirationSeconds;
    }
 
    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
}
# src/main/resources/application.yml
streaming:
  secret-key: ${STREAMING_SECRET_KEY:change-me-in-production}
  url-expiration-seconds: 7200
  base-url: ${STREAMING_BASE_URL:http://localhost/videos/hls}

URL Signing Service

// src/main/java/com/videoplatform/api/service/SignedUrlService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.config.StreamingConfig;
import org.springframework.stereotype.Service;
 
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
 
@Service
public class SignedUrlService {
 
    private final StreamingConfig config;
 
    public SignedUrlService(StreamingConfig config) {
        this.config = config;
    }
 
    /**
     * Generate a signed URL for HLS streaming.
     *
     * @param courseId  Course ID
     * @param lessonId Lesson ID
     * @param clientIp Client IP address (for IP binding)
     * @return Signed URL for the master playlist
     */
    public String generateSignedPlaylistUrl(Long courseId, Long lessonId, String clientIp) {
        String uri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
        long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
 
        String token = computeToken(uri, expires, clientIp);
 
        return String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
                config.getBaseUrl(), courseId, lessonId, token, expires);
    }
 
    /**
     * Generate a signed base URL for segment requests.
     * The player will append segment paths to this base.
     */
    public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
        long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
 
        // Sign the master playlist
        String masterUri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
        String masterToken = computeToken(masterUri, expires, clientIp);
        String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
                config.getBaseUrl(), courseId, lessonId, masterToken, expires);
 
        return new SignedStreamInfo(masterUrl, expires);
    }
 
    private String computeToken(String uri, long expires, String clientIp) {
        try {
            // Must match Nginx secure_link_md5 directive:
            // secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
            String raw = expires + uri + clientIp + config.getSecretKey();
 
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
 
            // Nginx expects Base64url encoding (no padding)
            return Base64.getUrlEncoder()
                    .withoutPadding()
                    .encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("Failed to compute secure link token", e);
        }
    }
 
    public record SignedStreamInfo(
        String masterPlaylistUrl,
        long expiresAt
    ) {}
}

Why MD5?

You might notice we're using MD5, which is cryptographically broken for collision resistance. That's fine here — secure_link uses MD5 as an HMAC-like construction (secret key + message). We're not using it for password hashing or digital signatures. The security comes from the secret key being unknown to the client, not from the hash algorithm's collision resistance. This is Nginx's design, and it works well for URL signing.


Streaming API Endpoint

Stream Controller

// src/main/java/com/videoplatform/api/controller/StreamController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.StreamResponse;
import com.videoplatform.api.entity.Lesson;
import com.videoplatform.api.entity.LessonStatus;
import com.videoplatform.api.exception.AccessDeniedException;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.service.SignedUrlService;
import com.videoplatform.api.service.SubscriptionService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/lessons")
public class StreamController {
 
    private final LessonRepository lessonRepository;
    private final SignedUrlService signedUrlService;
    private final SubscriptionService subscriptionService;
 
    public StreamController(
            LessonRepository lessonRepository,
            SignedUrlService signedUrlService,
            SubscriptionService subscriptionService) {
        this.lessonRepository = lessonRepository;
        this.signedUrlService = signedUrlService;
        this.subscriptionService = subscriptionService;
    }
 
    @GetMapping("/{lessonId}/stream")
    public ResponseEntity<ApiResponse<StreamResponse>> getStreamUrl(
            @PathVariable Long lessonId,
            @AuthenticationPrincipal UserDetails userDetails,
            HttpServletRequest request) {
 
        // 1. Find the lesson
        Lesson lesson = lessonRepository.findById(lessonId)
                .orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
 
        // 2. Check if video is ready
        if (lesson.getStatus() != LessonStatus.READY) {
            throw new ResourceNotFoundException("Video not available for this lesson");
        }
 
        // 3. Allow free preview lessons without subscription
        if (!lesson.isFreePreview()) {
            // 4. Check subscription
            boolean hasAccess = subscriptionService.hasActiveSubscription(userDetails.getUsername());
            if (!hasAccess) {
                throw new AccessDeniedException("Active subscription required to watch this lesson");
            }
        }
 
        // 5. Generate signed URL
        String clientIp = getClientIp(request);
        Long courseId = lesson.getSection().getCourse().getId();
 
        var streamInfo = signedUrlService.generateSignedStreamInfo(courseId, lessonId, clientIp);
 
        StreamResponse response = new StreamResponse(
            streamInfo.masterPlaylistUrl(),
            streamInfo.expiresAt(),
            lesson.getDuration()
        );
 
        return ResponseEntity.ok(ApiResponse.success(response));
    }
 
    private String getClientIp(HttpServletRequest request) {
        // Check X-Forwarded-For header (set by reverse proxy)
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isEmpty()) {
            // Take the first IP (client's real IP)
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Response DTO

// src/main/java/com/videoplatform/api/dto/response/StreamResponse.java
package com.videoplatform.api.dto.response;
 
public record StreamResponse(
    String playlistUrl,
    long expiresAt,
    int durationSeconds
) {}

Subscription Check

// src/main/java/com/videoplatform/api/service/SubscriptionService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
 
@Service
public class SubscriptionService {
 
    private final SubscriptionRepository subscriptionRepository;
 
    public SubscriptionService(SubscriptionRepository subscriptionRepository) {
        this.subscriptionRepository = subscriptionRepository;
    }
 
    public boolean hasActiveSubscription(String email) {
        return subscriptionRepository
                .findByUserEmailAndStatus(email, SubscriptionStatus.ACTIVE)
                .filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()))
                .isPresent();
    }
}
// src/main/java/com/videoplatform/api/repository/SubscriptionRepository.java
package com.videoplatform.api.repository;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import org.springframework.data.jpa.repository.JpaRepository;
 
import java.util.Optional;
 
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
    Optional<Subscription> findByUserEmailAndStatus(String email, SubscriptionStatus status);
}

Handling Segment URLs

There's a subtle problem. When hls.js fetches the master playlist, it gets back relative segment paths:

#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,NAME="720p"
720p/playlist.m3u8

The player resolves this relative to the master playlist URL. If the master URL is:

https://yoursite.com/videos/hls/1/1/master.m3u8?token=abc&expires=123

Then the player will request:

https://yoursite.com/videos/hls/1/1/720p/playlist.m3u8

Notice the problem? The token and expires parameters are stripped because they were only on the master URL. The segment request will get a 403 from Nginx.

Solution: Token-Aware Nginx Configuration

We modify the Nginx config to use a path-based token that covers the entire course/lesson directory:

# nginx/conf.d/hls.conf — updated
 
server {
    listen 80;
    server_name localhost;
 
    # Extract course and lesson IDs from the path
    # Pattern: /videos/hls/{courseId}/{lessonId}/...
    location ~ ^/videos/hls/(\d+)/(\d+)/ {
        set $course_id $1;
        set $lesson_id $2;
 
        # Secure link validation — hash covers the base path, not the full URI
        secure_link $arg_token,$arg_expires;
        secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
 
        # Invalid hash
        if ($secure_link = "") {
            return 403;
        }
 
        # Expired link
        if ($secure_link = "0") {
            return 410;
        }
 
        # Serve from video storage
        alias /data/videos/hls/$course_id/$lesson_id/;
 
        # Try the specific file
        try_files $uri =404;
 
        # Rate limiting
        limit_req zone=hls_limit burst=50 nodelay;
 
        # CORS headers
        add_header Access-Control-Allow-Origin $http_origin always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Range" always;
        add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
 
        # MIME types
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
        }
 
        # Cache control
        if ($uri ~* \.m3u8$) {
            add_header Cache-Control "no-cache";
        }
        if ($uri ~* \.ts$) {
            add_header Cache-Control "public, max-age=86400";
        }
    }
 
    # CORS preflight
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin $http_origin;
        add_header Access-Control-Allow-Methods "GET, OPTIONS";
        add_header Access-Control-Allow-Headers "Range";
        add_header Access-Control-Max-Age 86400;
        return 204;
    }
 
    # Block raw video access
    location /videos/raw/ {
        deny all;
    }
}

The key change is in secure_link_md5: instead of using $uri (which changes for every segment), we use the fixed base path /videos/hls/$course_id/$lesson_id/. This means one token works for all files under that lesson directory.

Update the Signing Service

// Update SignedUrlService.java — computeToken now uses base path
 
private String computeToken(String basePath, long expires, String clientIp) {
    try {
        // Must match Nginx secure_link_md5 directive:
        // secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr SECRET";
        String raw = expires + basePath + clientIp + config.getSecretKey();
 
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
 
        return Base64.getUrlEncoder()
                .withoutPadding()
                .encodeToString(hash);
    } catch (Exception e) {
        throw new RuntimeException("Failed to compute secure link token", e);
    }
}
 
public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
    long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
 
    // Sign against the base path (not the full URI)
    String basePath = String.format("/videos/hls/%d/%d/", courseId, lessonId);
    String token = computeToken(basePath, expires, clientIp);
 
    // Token will be appended to all requests under this path
    String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
            config.getBaseUrl(), courseId, lessonId, token, expires);
 
    return new SignedStreamInfo(masterUrl, token, expires);
}
 
public record SignedStreamInfo(
    String masterPlaylistUrl,
    String token,
    long expiresAt
) {}

hls.js Token Injection

On the frontend, configure hls.js to append the token to every request:

// web/src/lib/hls-config.ts
 
export function createHlsConfig(token: string, expires: number) {
  return {
    xhrSetup: (xhr: XMLHttpRequest, url: string) => {
      // Append token and expires to every HLS request
      const separator = url.includes("?") ? "&" : "?";
      const signedUrl = `${url}${separator}token=${token}&expires=${expires}`;
      xhr.open("GET", signedUrl, true);
    },
  };
}
// Usage in the video player component (we'll build this fully in Post #9)
import Hls from "hls.js";
import { createHlsConfig } from "@/lib/hls-config";
 
// When initializing hls.js:
const hls = new Hls(createHlsConfig(streamInfo.token, streamInfo.expiresAt));
hls.loadSource(streamInfo.masterPlaylistUrl);
hls.attachMedia(videoElement);

This way, every subrequest (quality playlists, segments) automatically includes the token. Nginx validates the same token for all requests under that lesson's path.


CORS Configuration

Cross-Origin Resource Sharing (CORS) is critical because the video player on your Next.js domain (app.yoursite.com) needs to fetch HLS segments from Nginx (cdn.yoursite.com or the same domain on a different port).

Why CORS Matters for Video

Without proper CORS headers:

  1. hls.js can't fetch the master playlist (blocked by browser)
  2. Quality switching fails (can't load alternative playlists)
  3. Range requests for seeking don't work

Nginx CORS Headers Explained

# Allow your frontend origin
add_header Access-Control-Allow-Origin $http_origin always;
 
# Only GET and OPTIONS needed for HLS
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
 
# Range header is essential for video seeking
add_header Access-Control-Allow-Headers "Range" always;
 
# Expose these so hls.js can read content length for progress
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;

Production tip: Replace $http_origin with your exact domain for tighter security:

# Instead of echoing back any origin:
add_header Access-Control-Allow-Origin $http_origin always;
 
# Use your specific domain:
add_header Access-Control-Allow-Origin "https://app.yoursite.com" always;

Spring Boot CORS for the Stream API

// src/main/java/com/videoplatform/api/config/CorsConfig.java
package com.videoplatform.api.config;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 
import java.util.List;
 
@Configuration
public class CorsConfig {
 
    @Value("${app.frontend-url:http://localhost:3000}")
    private String frontendUrl;
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(frontendUrl));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Preventing Direct Downloads

Even with signed URLs, a determined user could use developer tools to copy segment URLs and download them with curl. Here are layers of defense:

1. Short Expiration Times

Set URL expiration to 2 hours (enough for a long viewing session, but not for sharing):

streaming:
  url-expiration-seconds: 7200  # 2 hours

2. IP Binding

The $remote_addr in the hash ties the token to the viewer's IP. A URL copied to a different machine won't work:

secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";

3. Rate Limiting

Prevent automated bulk downloading:

# 30 requests per second per IP — enough for normal playback, blocks scrapers
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
limit_req zone=hls_limit burst=50 nodelay;

A 30-minute video has about 180 segments (at 10s each) across two qualities — roughly 360 segments total. Normal playback fetches these over 30 minutes. A scraper trying to download all segments at once will hit the rate limit.

4. Response Headers

# Remove Content-Disposition to prevent "Save As" dialog
add_header Content-Disposition "" always;
 
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
 
# Prevent embedding in iframes on other sites
add_header X-Frame-Options "SAMEORIGIN" always;

5. Referrer Checking (Optional)

Block requests that don't come from your site:

valid_referers server_names *.yoursite.com;
if ($invalid_referer) {
    return 403;
}

Defense in Depth Diagram

Reality check: No DRM-free streaming solution is 100% piracy-proof. A determined user can always screen-record. These measures prevent casual sharing and automated scraping — which covers 99% of cases. True DRM (Widevine/FairPlay) is covered in the security hardening post.


Docker Compose Configuration

Update your Docker Compose to include Nginx for HLS serving:

# docker-compose.yml — add nginx service
services:
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - video-storage:/data/videos:ro  # Read-only access to videos
    depends_on:
      - api
      - web
    restart: unless-stopped
 
  api:
    build: ./api
    ports:
      - "8080:8080"
    environment:
      STREAMING_SECRET_KEY: ${STREAMING_SECRET_KEY}
      STREAMING_BASE_URL: http://localhost/videos/hls
    volumes:
      - video-storage:/data/videos
    depends_on:
      - postgres
      - redis
 
  web:
    build: ./web
    ports:
      - "3000:3000"
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8080
      NEXT_PUBLIC_STREAM_BASE_URL: http://localhost/videos/hls
    depends_on:
      - api
 
  # ... postgres and redis services unchanged
 
volumes:
  video-storage:

Full Nginx Configuration with Reverse Proxy

In production, Nginx serves as both the reverse proxy for your app and the HLS server:

# nginx/conf.d/default.conf
 
upstream api {
    server api:8080;
}
 
upstream web {
    server web:3000;
}
 
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
 
server {
    listen 80;
    server_name yoursite.com;
 
    # API proxy
    location /api/ {
        proxy_pass http://api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # Important for multipart uploads
        client_max_body_size 2G;
    }
 
    # HLS streaming with secure_link
    location ~ ^/videos/hls/(\d+)/(\d+)/ {
        set $course_id $1;
        set $lesson_id $2;
 
        secure_link $arg_token,$arg_expires;
        secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
 
        if ($secure_link = "") {
            return 403;
        }
 
        if ($secure_link = "0") {
            return 410;
        }
 
        alias /data/videos/hls/$course_id/$lesson_id/;
        try_files $uri =404;
 
        limit_req zone=hls_limit burst=50 nodelay;
 
        add_header Access-Control-Allow-Origin "https://yoursite.com" always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Range" always;
        add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
 
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
        }
    }
 
    # Block raw video access
    location /videos/raw/ {
        deny all;
    }
 
    # Next.js frontend (catch-all)
    location / {
        proxy_pass http://web;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Environment Variables

Add the streaming secret to your .env:

# .env
STREAMING_SECRET_KEY=your-random-64-char-secret-key-here
STREAMING_BASE_URL=http://localhost/videos/hls
 
# Generate a random key:
# openssl rand -hex 32

Critical: The YOUR_SECRET_KEY in nginx.conf and STREAMING_SECRET_KEY in Spring Boot must match exactly. If they don't, every signed URL will fail validation.


Testing the Complete Flow

1. Test URL Signing

# Get a signed stream URL (requires auth token)
curl -s http://localhost:8080/api/lessons/1/stream \
  -H "Authorization: Bearer ${TOKEN}" | jq
 
# Response:
{
  "data": {
    "playlistUrl": "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567",
    "expiresAt": 1711234567,
    "durationSeconds": 1800
  }
}

2. Test Direct Access (Should Fail)

# Without token — should get 403
curl -v http://localhost/videos/hls/1/1/master.m3u8
# < HTTP/1.1 403 Forbidden
 
# With invalid token — should get 403
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=invalid&expires=9999999999"
# < HTTP/1.1 403 Forbidden

3. Test Signed Access (Should Work)

# Use the URL from step 1
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
# < Content-Type: application/vnd.apple.mpegurl
# #EXTM3U
# #EXT-X-VERSION:3
# ...

4. Test Segment Access with Same Token

# The same token should work for segments under the same lesson
curl -v "http://localhost/videos/hls/1/1/720p/playlist.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
 
curl -v "http://localhost/videos/hls/1/1/720p/segment-000.ts?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK

5. Test Expired URL

# Set expires to a past timestamp
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1000000000"
# < HTTP/1.1 410 Gone

6. Test Playback with ffplay

# Quick playback test using the signed URL
ffplay "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"

Nginx Debug Tips

When secure_link returns 403 and you're not sure why, use Nginx debug logging:

# In the server block, temporarily add:
error_log /var/log/nginx/error.log debug;

Then check the logs:

docker compose exec nginx tail -f /var/log/nginx/error.log

Common issues:

SymptomCauseFix
Always 403Secret key mismatchEnsure Nginx and Spring Boot use the same key
Always 403Hash encoding mismatchNginx expects Base64url without padding
Works locally, fails behind proxyIP mismatchEnsure X-Forwarded-For is set and read correctly
First request works, segments failToken tied to full URIUse base path in secure_link_md5 (our approach)
Expired too quicklyClock skewSync server clocks with NTP

Verify the Hash Manually

You can compute the expected hash yourself to debug:

# Variables — must match what Nginx expects
EXPIRES="1711234567"
BASE_PATH="/videos/hls/1/1/"
CLIENT_IP="172.18.0.1"
SECRET="your-secret-key"
 
# Compute MD5 (same as Nginx secure_link_md5)
echo -n "${EXPIRES}${BASE_PATH}${CLIENT_IP}${SECRET}" | \
  openssl md5 -binary | \
  openssl base64 | \
  tr '+/' '-_' | \
  tr -d '='
 
# Output should match the token parameter in the signed URL

Common Mistakes

1. Secret Key Mismatch Between Nginx and Spring Boot

This is the #1 issue. The key in nginx.conf and application.yml must be identical:

# nginx.conf
secure_link_md5 "...$remote_addr MY_SECRET_KEY";
# application.yml
streaming:
  secret-key: MY_SECRET_KEY  # Must match exactly!

Use an environment variable in both places to avoid drift.

2. Forgetting to Forward Client IP

Behind a reverse proxy, $remote_addr is the proxy's IP, not the client's. Spring Boot reads the real IP from X-Forwarded-For, but if Nginx's upstream proxy doesn't set this header, the IPs won't match:

# In the API proxy location
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

3. CORS Missing on Error Responses

Nginx's add_header doesn't apply to error responses by default. Use the always parameter:

# WRONG — CORS headers not sent on 403/410
add_header Access-Control-Allow-Origin $http_origin;
 
# RIGHT — CORS headers sent on all responses including errors
add_header Access-Control-Allow-Origin $http_origin always;

Without always, the browser can't even read the 403 status — it just sees a CORS error, making debugging impossible.

4. Caching Playlists

Never cache .m3u8 playlists aggressively. If the player gets a stale playlist, it might request segments that don't exist anymore (e.g., after re-transcoding):

# Playlists: never cache
location ~* \.m3u8$ {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
}
 
# Segments: cache aggressively (they never change)
location ~* \.ts$ {
    add_header Cache-Control "public, max-age=86400";
}

What's Next?

We have secure streaming working — signed URLs, token validation, CORS, and download prevention. But we still need a proper video player. In Post #9, we'll build a custom React video player:

  • hls.js integration with quality switching
  • Custom player controls (play/pause, seek, volume, fullscreen)
  • Playback speed selection
  • Resume from last position (progress saved to database)
  • Debounced progress saves with sendBeacon on page unload

Time to give users a beautiful way to watch those secured streams.

Series: Build a Video Streaming Platform
Previous: Phase 6: Video Transcoding Pipeline
Next: Phase 8: Video Player & Progress Tracking

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.