Secure Video Streaming with Nginx: Short-Lived Signed URLs & X-Accel-Redirect
This article details a secure video streaming architecture using short-lived HMAC-signed URLs and Nginx's X-Accel-Redirect to offload file transfer from the application, enabling efficient Range request handling and progress bar seeking while explaining mp4 moov atom placement requirements and common pitfalls.
Background
An employee portal serves ~40 MB MP4 videos from local disk via a Spring Boot application. The initial approach streamed videos directly from the application, causing two problems:
Authentication vs. playback conflict : Browsers' <video> tag does not send the Authorization header, making JWT validation impossible.
Application handles traffic inefficiently : Large file transfers, especially numerous Range requests from progress bar dragging, tie up Tomcat worker threads and reduce API throughput.
The goal: keep authentication logic in the application, delegate byte transfer to Nginx.
Solution Overview
<video src="/api/portal/videos/{id}/stream?expires=...&sign=...">
│
▼
Nginx (entry, reverse proxy /api/)
│
▼
Spring Boot (does only two things)
├── Verify signature (HMAC-SHA256 + expiry, constant-time comparison)
└── On success → response header X-Accel-Redirect: /_protected_videos/xxx.mp4
│
▼
Nginx internal location takes over file transfer
(sendfile + native HTTP Range, application never touches video bytes)Three key designs:
Short-lived signed URL (default 10 min) : <video> works without Authorization header; leaked URL has a tiny exploitation window.
X-Accel-Redirect internal redirect : Application only handles lightweight verification; Nginx streams the file directly.
internal location : External direct requests to /_protected_videos/ return 404, bypassing verification is impossible.
Key Implementation
3.1 Signing and Verifying Signed URLs
The signing endpoint (requires login) returns a playback URL with signature and expiry timestamp:
// Signature = HMAC-SHA256(videoId + ":" + expiryTimestamp), key configured separately, not reused with JWT/AES keys
public PlayUrl createPlayUrl(String id) {
long expiresAt = Instant.now().getEpochSecond() + urlTtlSeconds; // 600s
String sign = hmacSha256Hex(id + ":" + expiresAt, signKey);
return new PlayUrl("/api/portal/videos/" + id + "/stream?expires=" + expiresAt + "&sign=" + sign, expiresAt);
}Verification endpoint security details:
private boolean verifySignature(String id, long expires, String sign) {
if (sign == null || sign.isBlank() || expires < Instant.now().getEpochSecond()) {
return false;
}
// Must use constant-time comparison to prevent timing attacks
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
sign.getBytes(StandardCharsets.UTF_8));
}Constant-time comparison ( MessageDigest.isEqual): String.equals returns on first differing byte, allowing attackers to guess the signature byte by byte via response time differences.
Check expiry before verifying signature , saving one HMAC computation.
Video IDs are not real filenames; they use the first 16 characters of SHA-256(filename). At playback, the ID must exist in a pre-scanned directory whitelist, naturally blocking path traversal ( ../) because arbitrary strings cannot match a whitelist hash.
3.2 X-Accel-Redirect Diversion
The application switches behavior by deployment mode; local development falls back to direct streaming:
public void streamVideo(String id, long expires, String sign, ...) throws IOException {
if (!verifySignature(id, expires, sign)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
Path file = resolveFile(id); // whitelist lookup, prevents path traversal
if (properties.isAccelRedirect()) {
// Production: only return redirect header, Nginx takes over transfer
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(contentTypeOf(file));
response.setHeader("Cache-Control", "private, no-store");
response.setHeader("X-Accel-Redirect",
"/_protected_videos/" + URLEncoder.encode(file.getFileName().toString(), StandardCharsets.UTF_8));
return;
}
streamFileDirectly(file, request, response); // Dev fallback: application streams itself (supports Range)
}3.3 Nginx Configuration
server {
listen 60080; # high port to avoid conflict with existing apps
server_tokens off;
client_max_body_size 20m;
# API reverse proxy
location /api/ {
proxy_pass http://127.0.0.1:56789;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
# NOTE: do NOT set proxy_ignore_headers X-Accel-Redirect, or internal redirect will fail
}
# Video internal location: only accepts X-Accel-Redirect, external direct access returns 404
location /_protected_videos/ {
internal;
alias /videos/; # read-only mounted video directory
tcp_nopush on;
sendfile_max_chunk 512k;
output_buffers 2 1m;
}
}Key points: internal directive: this location only accepts Nginx internal redirects; external requests get 404 — even if someone obtains the filename they cannot download it.
Range segmentation requires no extra configuration; static files natively support Accept-Ranges: bytes, so <video> progress dragging works (principle explained in section 4).
Production uses Docker Compose to mount the same video directory to the application (read-write for scanning) and Nginx (read-only for serving).
3.4 Application Security Routing
In Spring Security, the playback endpoint is permitAll (authentication handled by signature), while other portal paths require login:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/captcha", "/auth/refresh").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/error").permitAll() // pitfall, see 5.1
.requestMatchers("/portal/videos/*/stream").permitAll()
.requestMatchers("/portal/**").hasRole("MEMBER")
...)Why Progress Bar Dragging Works: Two Prerequisites
"Seek" appears to be a player capability, but actually requires both the server and the file itself to cooperate; neither alone suffices.
4.1 First Layer: HTTP Range Requests (Transport Layer)
When the user drags the progress bar, the browser calculates the byte offset for the target timestamp and issues a request with a Range header:
GET /api/portal/videos/xxx/stream?...
Range: bytes=10485760-The server returns only content from that offset, responding with 206 Partial Content:
HTTP/1.1 206 Partial Content
Content-Range: bytes 10485760-37332623/37332624
Accept-Ranges: bytesNginx natively supports Range for static files — the Accept-Ranges: bytes header tells the browser "I can serve partial content", which is why Nginx proxies MP4 without any streaming module. The browser receives 206 and continues decoding from that position.
Three Range forms must be handled: bytes=start-end (range) bytes=start- (from start to end) bytes=-N (last N bytes, used by some players to probe file end)
In the application's direct-streaming fallback, these three forms must be parsed manually; out-of-bounds start must return 416 with Content-Range: bytes */total.
4.2 Second Layer: MP4 moov Atom at Front (File Layer)
With Range support, the player still needs to know "which byte corresponds to second N". This mapping is the moov atom (index/metadata) inside the MP4. It determines whether the file is "playable while downloading":
moov before mdat (audio/video data) : Browser gets index first, can seek arbitrarily with Range → ✅ Ready to use (faststart)
moov after mdat : Must download entire file before parsing index → ❌ Seeking fails, startup extremely slow
Therefore not all MP4s can be previewed directly . Export tools (especially some editors) write moov at the end by default; a post-processing step moves it to the front:
# Losslessly move moov to file head (no re-encode, completes in seconds)
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4Codec is another hidden prerequisite: browsers natively support H.264 (avc1) video + AAC (mp4a) audio ; H.265/VP9 etc. may not decode even with correct container, requiring transcoding ( ffmpeg -i in.mp4 -c:v libx264 -c:a aac out.mp4).
4.3 Pre-flight Check Before Deployment: No ffmpeg Required
Determining moov position doesn't need decoding; MP4 uses a standard atom (box) structure. A few dozen lines of Python can parse the file header (without ffmpeg):
# Approach: iterate top-level atoms by [4-byte size][4-byte type], compare order of moov and mdat
# moov first → output OK, ready for deployment
# moov last → output BAD, must run ffmpeg -movflags +faststart first
# Also parse fourcc in stsd atom to confirm avc1/mp4a, not codecs needing transcodePitfalls Encountered
5.1 401 Disguised as 403
On signature verification failure, the controller calls response.sendError(401). The servlet container internally forwards to /error for error page handling — but this forwarded request is intercepted by Spring Security (which did not permit /error), so the client receives 403. Symptom: direct app test of sendError works, but full chain changes the status.
Fix: Permit /error in security rules. Lesson: any endpoint that calls sendError must consider that the ERROR dispatch will be processed again by the security filter chain.
5.2 Nginx Cannot Read Video Files (Permission Denied)
Files uploaded via SFTP as root default to permissions 600, while Nginx worker runs as a non-root user. Container starts fine, but requests immediately return 403 with error log open() ... failed (13: Permission denied).
Fix: chmod 644 video files, 755 directories. Whenever "container runs but static files return 403/404", check the file permission chain first.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
