Back to blog

Build a Video Platform: Security & Performance Hardening

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Security & Performance Hardening

The features work. Users can sign up, subscribe, watch videos, and track progress. But "works" and "production-ready" are two different things. Right now, someone could hammer your login endpoint with brute-force attempts, your video segments are unencrypted on disk, there's no caching layer, and you have zero visibility into critical events like failed payments.

In this post, we'll systematically harden every layer — API rate limiting, video encryption, HTTP security headers, Redis caching, static page optimization, and event-driven email notifications.

Time commitment: 4–5 hours
Prerequisites: Phase 11: Admin Analytics & User Management

What we'll build in this post:
✅ API rate limiting with Spring Boot + Redis (Bucket4j)
✅ HLS AES-128 encryption for video segments
✅ CORS and Content Security Policy headers
✅ Redis caching for frequently accessed API responses
✅ Next.js Incremental Static Regeneration (ISR)
✅ Email notifications for critical events


Rate Limiting with Bucket4j + Redis

Why Rate Limiting?

Without rate limiting, a single client can:

  • Brute-force login credentials
  • Scrape your entire course catalog
  • Exhaust server resources with rapid API calls
  • Run up your Stripe API usage through repeated checkout attempts

We'll use Bucket4j — a Java rate limiting library based on the token bucket algorithm — backed by Redis for distributed rate limiting across multiple server instances.

Dependencies

<!-- pom.xml -->
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.7.0</version>
</dependency>
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.7.0</version>
</dependency>

Rate Limit Configuration

// src/main/java/com/videoplatform/api/config/RateLimitConfig.java
package com.videoplatform.api.config;
 
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import java.time.Duration;
 
@Configuration
public class RateLimitConfig {
 
    @Value("${spring.data.redis.host:localhost}")
    private String redisHost;
 
    @Value("${spring.data.redis.port:6379}")
    private int redisPort;
 
    @Bean
    public ProxyManager<String> proxyManager() {
        RedisClient redisClient = RedisClient.create(
                String.format("redis://%s:%d", redisHost, redisPort));
        StatefulRedisConnection<String, String> connection = redisClient.connect();
        return LettuceBasedProxyManager.builderFor(connection)
                .build();
    }
 
    // General API: 60 requests/minute
    @Bean("apiRateLimit")
    public BucketConfiguration apiRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(60, Duration.ofMinutes(1)))
                .build();
    }
 
    // Auth endpoints: 10 requests/minute (stricter)
    @Bean("authRateLimit")
    public BucketConfiguration authRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(10, Duration.ofMinutes(1)))
                .build();
    }
 
    // Webhook endpoints: 100 requests/minute (Stripe sends bursts)
    @Bean("webhookRateLimit")
    public BucketConfiguration webhookRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(100, Duration.ofMinutes(1)))
                .build();
    }
}

Rate Limit Filter

// src/main/java/com/videoplatform/api/filter/RateLimitFilter.java
package com.videoplatform.api.filter;
 
import io.github.bucket4j.Bucket;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
import java.util.function.Supplier;
 
@Component
public class RateLimitFilter extends OncePerRequestFilter {
 
    private final ProxyManager<String> proxyManager;
    private final BucketConfiguration apiConfig;
    private final BucketConfiguration authConfig;
    private final BucketConfiguration webhookConfig;
 
    public RateLimitFilter(
            ProxyManager<String> proxyManager,
            @Qualifier("apiRateLimit") BucketConfiguration apiConfig,
            @Qualifier("authRateLimit") BucketConfiguration authConfig,
            @Qualifier("webhookRateLimit") BucketConfiguration webhookConfig) {
        this.proxyManager = proxyManager;
        this.apiConfig = apiConfig;
        this.authConfig = authConfig;
        this.webhookConfig = webhookConfig;
    }
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
 
        String clientIp = getClientIp(request);
        String path = request.getRequestURI();
 
        // Select rate limit config based on path
        BucketConfiguration config;
        String bucketKey;
 
        if (path.startsWith("/api/auth/")) {
            config = authConfig;
            bucketKey = "rate:auth:" + clientIp;
        } else if (path.startsWith("/api/webhooks/")) {
            config = webhookConfig;
            bucketKey = "rate:webhook:" + clientIp;
        } else if (path.startsWith("/api/")) {
            config = apiConfig;
            bucketKey = "rate:api:" + clientIp;
        } else {
            // Not an API request — skip rate limiting
            filterChain.doFilter(request, response);
            return;
        }
 
        Supplier<BucketConfiguration> configSupplier = () -> config;
        Bucket bucket = proxyManager.builder()
                .build(bucketKey, configSupplier);
 
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
 
        if (probe.isConsumed()) {
            // Add rate limit headers
            response.addHeader("X-Rate-Limit-Remaining",
                    String.valueOf(probe.getRemainingTokens()));
            filterChain.doFilter(request, response);
        } else {
            // Rate limit exceeded
            long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("Retry-After", String.valueOf(waitSeconds));
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType("application/json");
            response.getWriter().write("""
                    {"error": "Rate limit exceeded", "retryAfter": %d}
                    """.formatted(waitSeconds));
        }
    }
 
    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isEmpty()) {
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

The filter uses a token bucket per IP address. Auth endpoints get a stricter limit (10/min) to prevent brute-force attacks, while webhook endpoints get a higher limit (100/min) because Stripe can send event bursts. The X-Rate-Limit-Remaining and Retry-After headers help clients back off gracefully.

Register the Filter

// Add to SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(
        HttpSecurity http, RateLimitFilter rateLimitFilter) throws Exception {
 
    http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);
 
    // ... rest of config
}

HLS AES-128 Encryption

Why Encrypt Video Segments?

Even with signed URLs, once a .ts segment is downloaded, it's playable forever. AES-128 encryption means each segment is encrypted — even if someone saves the files, they can't play them without the decryption key.

Encryption Flow

Updated Transcoding with Encryption

// src/main/java/com/videoplatform/api/service/TranscodingService.java
// Updated transcodeToHls method
 
private void transcodeToHls(Path inputFile, Path outputDir, String resolution,
                             int width, int height) throws IOException, InterruptedException {
 
    Path resDir = outputDir.resolve(resolution);
    Files.createDirectories(resDir);
 
    // Generate encryption key
    Path keyFile = outputDir.resolve("enc.key");
    Path keyInfoFile = outputDir.resolve("enc.keyinfo");
 
    if (!Files.exists(keyFile)) {
        // Generate random 16-byte AES key
        byte[] key = new byte[16];
        new java.security.SecureRandom().nextBytes(key);
        Files.write(keyFile, key);
 
        // Generate random IV
        byte[] iv = new byte[16];
        new java.security.SecureRandom().nextBytes(iv);
        String ivHex = bytesToHex(iv);
 
        // Key info file: key URL, key file path, IV
        // The key URL is where hls.js will fetch the key during playback
        String keyInfo = String.format(
                "%s/api/videos/key/{lessonId}\n%s\n%s",
                appBaseUrl, keyFile.toAbsolutePath(), ivHex);
        Files.write(keyInfoFile, keyInfo.getBytes());
    }
 
    List<String> command = List.of(
            "ffmpeg", "-i", inputFile.toString(),
            "-vf", String.format("scale=%d:%d", width, height),
            "-c:v", "libx264", "-preset", "medium",
            "-c:a", "aac", "-b:a", "128k",
            "-hls_time", "6",
            "-hls_list_size", "0",
            "-hls_segment_filename", resDir.resolve("seg%03d.ts").toString(),
            "-hls_key_info_file", keyInfoFile.toString(),  // Enable encryption
            "-f", "hls",
            resDir.resolve("playlist.m3u8").toString()
    );
 
    ProcessBuilder pb = new ProcessBuilder(command);
    pb.redirectErrorStream(true);
    Process process = pb.start();
 
    int exitCode = process.waitFor();
    if (exitCode != 0) {
        throw new RuntimeException("FFmpeg transcoding failed with exit code: " + exitCode);
    }
}
 
private static String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append(String.format("%02x", b));
    }
    return sb.toString();
}

The key change is -hls_key_info_file — this tells FFmpeg to encrypt every .ts segment with AES-128. The key info file contains three lines: the URL where the player fetches the key, the local path to the key file, and the initialization vector (IV).

Key Delivery Endpoint

// src/main/java/com/videoplatform/api/controller/VideoKeyController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.service.SubscriptionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.*;
 
import java.nio.file.Files;
import java.nio.file.Path;
 
@RestController
@RequestMapping("/api/videos")
public class VideoKeyController {
 
    private final SubscriptionService subscriptionService;
    private final String videoStoragePath;
 
    public VideoKeyController(
            SubscriptionService subscriptionService,
            @Value("${video.storage.path}") String videoStoragePath) {
        this.subscriptionService = subscriptionService;
        this.videoStoragePath = videoStoragePath;
    }
 
    @GetMapping("/key/{lessonId}")
    public ResponseEntity<byte[]> getDecryptionKey(
            @PathVariable Long lessonId,
            @AuthenticationPrincipal UserDetails userDetails) {
 
        // Verify active subscription
        if (!subscriptionService.hasActiveSubscription(userDetails.getUsername())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
 
        try {
            Path keyFile = Path.of(videoStoragePath, "lessons", lessonId.toString(), "enc.key");
            byte[] key = Files.readAllBytes(keyFile);
 
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header("Cache-Control", "no-store") // Never cache the key
                    .body(key);
        } catch (Exception e) {
            return ResponseEntity.notFound().build();
        }
    }
}

The key endpoint requires both authentication (JWT) and an active subscription. The Cache-Control: no-store header ensures the browser never caches the encryption key — every playback session must re-authenticate.

Generated M3U8 with Encryption

After transcoding, the .m3u8 playlist looks like this:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/api/videos/key/42",IV=0x9a3b...
#EXTINF:6.000,
seg000.ts
#EXTINF:6.000,
seg001.ts
#EXTINF:4.320,
seg002.ts
#EXT-X-ENDLIST

hls.js reads the #EXT-X-KEY tag, fetches the key from our API (with the JWT cookie/header), and decrypts each segment before playback. This all happens transparently — no changes needed in the player component from Post #9.


CORS & Security Headers

CORS Configuration

// 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.cors.allowed-origins}")
    private List<String> allowedOrigins;
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins);
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of(
                "Authorization", "Content-Type", "X-Requested-With"));
        config.setExposedHeaders(List.of(
                "X-Rate-Limit-Remaining", "Retry-After"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L); // Cache preflight for 1 hour
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}
# application.yml
app:
  cors:
    allowed-origins:
      - https://yourdomain.com
      - http://localhost:3000  # Dev only

Security Headers with Nginx

# /etc/nginx/conf.d/security-headers.conf
 
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
 
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
 
# XSS protection (legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;
 
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 
# Permissions policy — disable unused browser APIs
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self)" always;
 
# Strict Transport Security (HTTPS only)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
 
# Content Security Policy
add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self' 'unsafe-inline' https://js.stripe.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self';
    connect-src 'self' https://api.stripe.com;
    frame-src https://js.stripe.com https://hooks.stripe.com;
    media-src 'self' blob:;
    object-src 'none';
    base-uri 'self';
" always;

The CSP is the most critical header. Let's break it down:

  • script-src: Only our domain + Stripe.js (for checkout)
  • connect-src: API calls to self + Stripe API
  • frame-src: Stripe's iframe for checkout and 3D Secure
  • media-src: Self + blob: (hls.js creates blob URLs for video playback)
  • object-src 'none': Block all plugins (Flash, Java applets)

Nginx HTTPS Configuration

# /etc/nginx/sites-available/videoplatform
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}
 
server {
    listen 443 ssl http2;
    server_name yourdomain.com;
 
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
 
    # Modern TLS config
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
 
    # Include security headers
    include /etc/nginx/conf.d/security-headers.conf;
 
    # API proxy
    location /api/ {
        proxy_pass http://localhost:8080;
        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;
    }
 
    # Next.js
    location / {
        proxy_pass http://localhost:3000;
        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;
    }
 
    # HLS video serving (with secure_link from Post #8)
    location /videos/hls/ {
        internal;
        alias /var/videos/hls/;
        add_header Cache-Control "private, max-age=86400";
        add_header X-Content-Type-Options "nosniff";
    }
}

Redis Caching

Cache Configuration

// src/main/java/com/videoplatform/api/config/CacheConfig.java
package com.videoplatform.api.config;
 
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
 
import java.time.Duration;
import java.util.Map;
 
@Configuration
@EnableCaching
public class CacheConfig {
 
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Default: 5 minutes
        RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
 
        // Per-cache TTLs
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
                "courses", defaults.entryTtl(Duration.ofMinutes(15)),
                "course-detail", defaults.entryTtl(Duration.ofMinutes(10)),
                "public-courses", defaults.entryTtl(Duration.ofMinutes(15)),
                "dashboard-metrics", defaults.entryTtl(Duration.ofMinutes(2)),
                "popular-lessons", defaults.entryTtl(Duration.ofMinutes(5)),
                "user-subscription", defaults.entryTtl(Duration.ofMinutes(3))
        );
 
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaults)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

Applying Cache to Services

// PublicCourseService — cache course listings and details
@Service
public class PublicCourseService {
 
    @Cacheable(value = "public-courses", key = "'all'")
    @Transactional(readOnly = true)
    public List<PublicCourseListResponse> getAllPublishedCourses() {
        return courseRepository.findByStatus(CourseStatus.PUBLISHED)
                .stream()
                .map(this::toListResponse)
                .toList();
    }
 
    @Cacheable(value = "course-detail", key = "#slug")
    @Transactional(readOnly = true)
    public PublicCourseDetailResponse getCourseBySlug(String slug) {
        Course course = courseRepository.findBySlugAndStatus(slug, CourseStatus.PUBLISHED)
                .orElseThrow(() -> new ResourceNotFoundException("Course not found"));
        return toDetailResponse(course);
    }
 
    // Evict cache when course is updated
    @CacheEvict(value = {"public-courses", "course-detail"}, allEntries = true)
    @Transactional
    public void updateCourse(Long id, UpdateCourseRequest request) {
        // ... update logic
    }
}
// AnalyticsService — cache dashboard metrics
@Cacheable(value = "dashboard-metrics", key = "'current'")
@Transactional(readOnly = true)
public DashboardMetrics getDashboardMetrics() {
    // ... expensive aggregation queries
}
 
@Cacheable(value = "popular-lessons", key = "#limit")
@Transactional(readOnly = true)
public List<PopularLessonResponse> getPopularLessons(int limit) {
    return progressRepository.findPopularLessons(limit);
}
// SubscriptionService — cache subscription checks
@Cacheable(value = "user-subscription", key = "#email")
public boolean hasActiveSubscription(String email) {
    return subscriptionRepository
            .existsByUserEmailAndStatus(email, SubscriptionStatus.ACTIVE);
}
 
// Evict when subscription changes
@CacheEvict(value = "user-subscription", key = "#email")
public void onSubscriptionChanged(String email) {
    // Cache eviction only
}

Cache Strategy Summary

CacheTTLEvictionWhy
public-courses15 minOn course updateCourse list rarely changes
course-detail10 minOn course updateIndividual course pages
dashboard-metrics2 minTime-basedAdmin needs near-real-time data
popular-lessons5 minTime-basedAcceptable staleness
user-subscription3 minOn subscription changeFrequent access check, webhook evicts

Next.js Incremental Static Regeneration

ISR for Course Pages

// web/src/app/courses/[slug]/page.tsx
import { Metadata } from "next";
 
const API_BASE = process.env.API_URL || "http://localhost:8080";
 
// Revalidate every 5 minutes
export const revalidate = 300;
 
export async function generateStaticParams() {
  const res = await fetch(`${API_BASE}/api/public/courses`);
  const data = await res.json();
 
  return data.data.map((course: { slug: string }) => ({
    slug: course.slug,
  }));
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
  const course = data.data;
 
  return {
    title: course.title,
    description: course.shortDescription,
    openGraph: {
      title: course.title,
      description: course.shortDescription,
      images: [course.thumbnailUrl],
    },
  };
}
 
export default async function CoursePage({ params }: Props) {
  const { slug } = await params;
  const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
  const course = data.data;
 
  return (
    <main>
      <CourseHero course={course} />
      <CourseSyllabus sections={course.sections} />
    </main>
  );
}

ISR for Catalog Page

// web/src/app/courses/page.tsx
export const revalidate = 300; // 5 minutes
 
export default async function CatalogPage() {
  const res = await fetch(`${API_BASE}/api/public/courses`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
 
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Course Catalog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {data.data.map((course: Course) => (
          <CourseCard key={course.id} course={course} />
        ))}
      </div>
    </main>
  );
}

On-Demand Revalidation

When an admin updates a course, trigger ISR revalidation:

// web/src/app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(request: NextRequest) {
  const secret = request.headers.get("x-revalidation-secret");
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
  }
 
  const { path } = await request.json();
 
  revalidatePath(path);
  revalidatePath("/courses"); // Also revalidate catalog
 
  return NextResponse.json({ revalidated: true });
}
// Call from Spring Boot when course is updated
@Service
public class RevalidationService {
 
    @Value("${app.nextjs.url}")
    private String nextjsUrl;
 
    @Value("${app.nextjs.revalidation-secret}")
    private String revalidationSecret;
 
    private final RestTemplate restTemplate;
 
    public void revalidateCoursePage(String slug) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("x-revalidation-secret", revalidationSecret);
        headers.setContentType(MediaType.APPLICATION_JSON);
 
        Map<String, String> body = Map.of("path", "/courses/" + slug);
        HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
 
        try {
            restTemplate.postForEntity(
                    nextjsUrl + "/api/revalidate", request, String.class);
        } catch (Exception e) {
            // Log but don't fail — ISR will still refresh on TTL
            log.warn("Failed to trigger ISR revalidation for {}: {}", slug, e.getMessage());
        }
    }
}

The ISR flow:

  1. First request: Next.js builds the page statically and serves it
  2. Subsequent requests within 5 minutes: Serve cached static page (instant)
  3. After 5 minutes: Next request triggers background regeneration, stale page served immediately
  4. On-demand: Admin update triggers immediate revalidation via API

Email Notifications

Notification Events

// src/main/java/com/videoplatform/api/event/NotificationEvent.java
package com.videoplatform.api.event;
 
public record NotificationEvent(
    NotificationType type,
    String recipientEmail,
    String recipientName,
    java.util.Map<String, String> data
) {
    public enum NotificationType {
        WELCOME,
        SUBSCRIPTION_ACTIVATED,
        SUBSCRIPTION_EXPIRING_SOON,
        PAYMENT_FAILED,
        COURSE_COMPLETED
    }
}

Event Publisher

// Publish events from services
@Service
public class SubscriptionWebhookService {
 
    private final ApplicationEventPublisher eventPublisher;
 
    // In handleCheckoutCompleted:
    private void onNewSubscription(User user, Subscription subscription) {
        // ... save subscription
 
        eventPublisher.publishEvent(new NotificationEvent(
                NotificationEvent.NotificationType.SUBSCRIPTION_ACTIVATED,
                user.getEmail(),
                user.getName(),
                Map.of(
                        "planType", subscription.getPlanType().name(),
                        "endDate", subscription.getCurrentPeriodEnd().toString()
                )
        ));
    }
 
    // In handleInvoicePaymentFailed:
    private void onPaymentFailed(User user) {
        eventPublisher.publishEvent(new NotificationEvent(
                NotificationEvent.NotificationType.PAYMENT_FAILED,
                user.getEmail(),
                user.getName(),
                Map.of("portalUrl", stripeService.createPortalSession(user.getEmail()))
        ));
    }
}

Async Email Listener

// src/main/java/com/videoplatform/api/listener/NotificationListener.java
package com.videoplatform.api.listener;
 
import com.videoplatform.api.event.NotificationEvent;
import com.videoplatform.api.service.EmailService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
 
@Component
public class NotificationListener {
 
    private static final Logger log = LoggerFactory.getLogger(NotificationListener.class);
    private final EmailService emailService;
 
    public NotificationListener(EmailService emailService) {
        this.emailService = emailService;
    }
 
    @Async
    @EventListener
    public void handleNotification(NotificationEvent event) {
        try {
            switch (event.type()) {
                case WELCOME -> emailService.sendWelcomeEmail(
                        event.recipientEmail(), event.recipientName());
 
                case SUBSCRIPTION_ACTIVATED -> emailService.sendSubscriptionActivated(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("planType"),
                        event.data().get("endDate"));
 
                case PAYMENT_FAILED -> emailService.sendPaymentFailedEmail(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("portalUrl"));
 
                case SUBSCRIPTION_EXPIRING_SOON -> emailService.sendExpiringNotification(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("daysRemaining"));
 
                case COURSE_COMPLETED -> emailService.sendCourseCompletedEmail(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("courseTitle"));
            }
 
            log.info("Notification sent: {} to {}", event.type(), event.recipientEmail());
        } catch (Exception e) {
            log.error("Failed to send notification: {} to {}",
                    event.type(), event.recipientEmail(), e);
        }
    }
}

Email Service

// src/main/java/com/videoplatform/api/service/EmailService.java
package com.videoplatform.api.service;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
 
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
 
@Service
public class EmailService {
 
    private final JavaMailSender mailSender;
 
    @Value("${app.email.from}")
    private String fromEmail;
 
    @Value("${app.base-url}")
    private String baseUrl;
 
    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }
 
    public void sendSubscriptionActivated(
            String to, String name, String planType, String endDate) throws MessagingException {
 
        String subject = "Your subscription is now active!";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Welcome aboard, %s!</h1>
                    <p>Your <strong>%s</strong> subscription is now active.</p>
                    <p>You have full access to all courses and lessons until <strong>%s</strong>.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #6366f1;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Start Learning
                    </a>
                    <p style="color: #666; margin-top: 24px; font-size: 14px;">
                        Questions? Reply to this email — we're here to help.
                    </p>
                </div>
                """.formatted(name, planType, endDate, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendPaymentFailedEmail(
            String to, String name, String portalUrl) throws MessagingException {
 
        String subject = "Action needed: Payment failed";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Payment Issue</h1>
                    <p>Hi %s, we couldn't process your latest payment.</p>
                    <p>Your subscription is still active, but please update your payment method
                       to avoid interruption.</p>
                    <a href="%s"
                       style="display: inline-block; padding: 12px 24px; background: #ef4444;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Update Payment Method
                    </a>
                </div>
                """.formatted(name, portalUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendCourseCompletedEmail(
            String to, String name, String courseTitle) throws MessagingException {
 
        String subject = "Congratulations! You completed " + courseTitle;
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Course Complete!</h1>
                    <p>Amazing work, %s! You've completed <strong>%s</strong>.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #22c55e;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Explore More Courses
                    </a>
                </div>
                """.formatted(name, courseTitle, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendWelcomeEmail(String to, String name) throws MessagingException {
        String subject = "Welcome to the platform!";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Welcome, %s!</h1>
                    <p>Thanks for joining. Browse our courses and start learning today.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #6366f1;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Browse Courses
                    </a>
                </div>
                """.formatted(name, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendExpiringNotification(
            String to, String name, String daysRemaining) throws MessagingException {
 
        String subject = "Your subscription expires in " + daysRemaining + " days";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Subscription Expiring Soon</h1>
                    <p>Hi %s, your subscription expires in <strong>%s days</strong>.</p>
                    <p>Renew now to keep your access to all courses.</p>
                    <a href="%s/pricing"
                       style="display: inline-block; padding: 12px 24px; background: #f59e0b;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Renew Subscription
                    </a>
                </div>
                """.formatted(name, daysRemaining, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    private void sendHtmlEmail(String to, String subject, String html) throws MessagingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        helper.setFrom(fromEmail);
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(html, true);
        mailSender.send(message);
    }
}

Scheduled Expiration Check

// src/main/java/com/videoplatform/api/scheduler/SubscriptionScheduler.java
package com.videoplatform.api.scheduler;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.event.NotificationEvent;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
 
@Component
public class SubscriptionScheduler {
 
    private final SubscriptionRepository subscriptionRepository;
    private final ApplicationEventPublisher eventPublisher;
 
    public SubscriptionScheduler(
            SubscriptionRepository subscriptionRepository,
            ApplicationEventPublisher eventPublisher) {
        this.subscriptionRepository = subscriptionRepository;
        this.eventPublisher = eventPublisher;
    }
 
    // Run daily at 9 AM
    @Scheduled(cron = "0 0 9 * * *")
    public void checkExpiringSubscriptions() {
        // Find subscriptions expiring in 3 days
        LocalDateTime threeDaysFromNow = LocalDateTime.now().plusDays(3);
        LocalDateTime fourDaysFromNow = LocalDateTime.now().plusDays(4);
 
        List<Subscription> expiring = subscriptionRepository
                .findByStatusAndCurrentPeriodEndBetween(
                        SubscriptionStatus.ACTIVE,
                        threeDaysFromNow, fourDaysFromNow);
 
        for (Subscription sub : expiring) {
            eventPublisher.publishEvent(new NotificationEvent(
                    NotificationEvent.NotificationType.SUBSCRIPTION_EXPIRING_SOON,
                    sub.getUser().getEmail(),
                    sub.getUser().getName(),
                    Map.of("daysRemaining", "3")
            ));
        }
    }
}

Mail Configuration

# application.yml
spring:
  mail:
    host: smtp.gmail.com    # Or your SMTP provider
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
 
app:
  email:
    from: noreply@yourdomain.com
  base-url: https://yourdomain.com

Testing

1. Rate Limiting

# Hit the login endpoint 11 times rapidly
for i in $(seq 1 11); do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST http://localhost:8080/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"test@test.com","password":"wrong"}')
  echo "Request $i: HTTP $STATUS"
done
 
# Expected: first 10 return 401 (wrong password), 11th returns 429

2. HLS Encryption

# Check that .ts files are encrypted (not playable directly)
file /var/videos/hls/lessons/1/720p/seg000.ts
# Should show: "data" (encrypted), not "MPEG transport stream"
 
# Check m3u8 has encryption tag
grep "EXT-X-KEY" /var/videos/hls/lessons/1/720p/playlist.m3u8
# Should show: #EXT-X-KEY:METHOD=AES-128,URI="...",IV=0x...

3. Security Headers

curl -I https://yourdomain.com | grep -E "(Content-Security|X-Frame|X-Content-Type|Strict-Transport)"

4. Redis Cache

# Check Redis keys after API calls
redis-cli keys "public-courses*"
redis-cli keys "dashboard-metrics*"
redis-cli ttl "public-courses::all"  # Should show ~900 (15 min in seconds)

5. ISR Verification

# First request — generates static page
curl -s -w "\nTime: %{time_total}s\n" http://localhost:3000/courses/java-basics
 
# Second request — served from cache (should be faster)
curl -s -w "\nTime: %{time_total}s\n" http://localhost:3000/courses/java-basics

Common Mistakes

1. Rate Limiting by User ID Only

// WRONG — unauthenticated requests bypass rate limiting
String key = "rate:" + currentUser.getId();
 
// RIGHT — rate limit by IP for unauthenticated, by user for authenticated
String key = isAuthenticated
    ? "rate:user:" + currentUser.getId()
    : "rate:ip:" + getClientIp(request);

2. Caching User-Specific Data

// WRONG — all users see the same cached subscription status
@Cacheable("subscription")
public boolean hasActiveSubscription(String email) { ... }
 
// RIGHT — include user identifier in cache key
@Cacheable(value = "user-subscription", key = "#email")
public boolean hasActiveSubscription(String email) { ... }

3. Forgetting to Evict Cache

// WRONG — stale course data after update
@Transactional
public void updateCourse(Long id, UpdateCourseRequest request) {
    // ... save changes but cache still has old data
}
 
// RIGHT — evict related caches
@CacheEvict(value = {"public-courses", "course-detail"}, allEntries = true)
@Transactional
public void updateCourse(Long id, UpdateCourseRequest request) {
    // ... save changes, caches cleared
}

4. Blocking on Email Sending

// WRONG — API response waits for email to send
public void onNewSubscription(User user) {
    subscriptionRepository.save(subscription);
    emailService.sendSubscriptionActivated(user.getEmail(), ...); // Blocks!
}
 
// RIGHT — async event-driven
public void onNewSubscription(User user) {
    subscriptionRepository.save(subscription);
    eventPublisher.publishEvent(new NotificationEvent(...)); // Non-blocking
}

What's Next?

The platform is now hardened — rate-limited APIs, encrypted video, security headers, caching, and proactive notifications. In Post #14, we'll add comprehensive testing:

  • JUnit + Mockito for unit tests
  • Spring Boot test slices for API tests
  • Testcontainers for Postgres/Redis integration
  • Playwright for frontend E2E tests
  • Stripe webhook testing

Time to make sure everything we've built actually works correctly.

Series: Build a Video Streaming Platform
Previous: Phase 11: Admin Analytics & User Management
Next: Phase 13: Testing Strategy

📬 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.