Giải pháp · 03/10/2026

Practical NGINX X-Accel-Redirect: Secure Private Downloads

When an application reads and streams a multi-gigabyte file itself, every download can occupy runtime workers, memory, and connections for a long time. X-Accel-Redirect creates a clean split: the application authenticates and authorizes, while NGINX transfers the file from private storage.

NGINX X-Accel-Redirect thực chiến: Tải file riêng tư an toàn

When an application reads and streams a multi-gigabyte file itself, every download can occupy runtime workers, memory, and connections for a long time. X-Accel-Redirect creates a clean split: the application authenticates and authorizes, while NGINX transfers the file from private storage.

This guide builds a production download flow with an internal location, alias, an upstream response header, safe filenames, Range requests, rate limits, logging, and tests. It follows official NGINX documentation for internal requests, alias, sendfile, and X-Accel header processing.

1. Architecture: the app owns access, NGINX owns transfer

  1. The client calls a business endpoint such as GET /downloads/8f2....
  2. The application authenticates the user, loads the file record, and checks tenant, plan, state, or expiry policy.
  3. Denied requests receive the normal 401, 403, or 404 response.
  4. For an allowed request, the app returns a small response with X-Accel-Redirect pointing to an internal URI.
  5. NGINX performs the internal redirect, maps the URI to storage, and sends the content.

The client never receives a filesystem path or needs a public storage URL. The application remains the single policy decision point; NGINX executes that decision for the request.

2. Minimal NGINX configuration

server {
    listen 443 ssl;
    server_name files.example.com;

    location / {
        proxy_pass http://app;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Request-Id $request_id;
    }

    location /_protected/ {
        internal;
        alias /srv/private-downloads/;
        sendfile on;

        add_header Cache-Control "private, no-store" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
}

Here, /_protected/tenant-7/report.pdf maps to /srv/private-downloads/tenant-7/report.pdf. Keep the trailing slashes on the location and alias consistent. internal makes a direct client request return 404 while still allowing an internal redirect generated by X-Accel-Redirect.

3. The backend returns only a decision and metadata

// PHP/Laravel-style pseudocode
function download(string $publicId, User $user): Response
{
    $file = FileRecord::findByPublicId($publicId);

    if (!$file || !$user->can('download', $file)) {
        abort(404); // Avoid disclosing whether the file exists.
    }

    $internalUri = '/_protected/' . $file->storage_key;
    $filename = safeDownloadName($file->display_name);

    return response('', 200)
        ->header('X-Accel-Redirect', $internalUri)
        ->header('Content-Type', $file->mime_type)
        ->header('Content-Disposition',
            'attachment; filename="download"; filename*=UTF-8\'\''
            . rawurlencode($filename));
}

The storage_key must be server-generated and loaded from the database, for example tenant-7/9a/9a71...bin. Never concatenate a client-supplied filename, query value, or absolute path into X-Accel-Redirect.

4. Prevent path traversal by data design

Rejecting ../ is necessary but not sufficient when encodings, separators, and symlinks exist. Prefer a design where:

  • The client supplies only an opaque public ID.
  • The database maps that ID to an immutable, server-generated storage key.
  • The key uses a narrow character set with no ., .., NUL, backslash, or leading slash segments.
  • Files live outside the document root and the NGINX worker has only the required read permissions.
  • The upload pipeline generates physical names; original names remain display metadata.
  • Symlinks have an explicit policy; otherwise consider disable_symlinks and benchmark it on the real filesystem.

Do not put an absolute path in the header. X-Accel-Redirect carries an internal URI that NGINX maps through operator-controlled configuration.

5. Keep Content-Disposition from becoming header injection

Display names can contain Unicode, quotes, or line breaks. Remove CR/LF and control characters, bound the length, provide a simple ASCII fallback, and encode the UTF-8 filename* parameter. Never interpolate an unnormalized original name into a header.

function safeDownloadName(string $name): string
{
    $name = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? '';
    $name = trim(str_replace(['/', '\\'], '-', $name));
    return mb_substr($name !== '' ? $name : 'download', 0, 180);
}

Use MIME metadata verified at upload time or an allowlist. X-Content-Type-Options: nosniff also discourages browsers from guessing a different content type.

6. Range requests, resumes, and large files

Once NGINX serves the file as a static resource, it can handle byte ranges and HEAD through its HTTP file-serving behavior. Download managers, media clients, and resumable transfers no longer require application-level seek and chunk logic.

sendfile on lets NGINX use the operating system's sendfile() mechanism where appropriate. Actual performance still depends on the filesystem, file size, page cache, and kernel, so benchmark instead of treating one directive as an unconditional guarantee.

Every request must still pass the business authorization endpoint. A new Range request must not gain a direct path into the internal location.

7. Rate limits and resource protection

location /_protected/ {
    internal;
    alias /srv/private-downloads/;
    sendfile on;

    limit_rate_after 10m;
    limit_rate 8m;
}

limit_rate applies per request, so concurrent connections can aggregate bandwidth. NGINX can also consume X-Accel-Limit-Rate from an upstream in supported integrations, but only a trusted backend should choose dynamic values.

Transfer throttling does not replace authorization request limits, connection controls, tenant quotas, or bot protection. Large files also need suitable timeouts and disk/network capacity planning.

8. Signed URLs and one-time tokens

A signed URL is useful for a short-lived share that does not rely on a browser session. Bind its signature to at least the file ID, expiry, action scope, and nonce, and compare signatures in constant time. A valid signature does not automatically prove that an account should retain access if policy requires a current state check.

A “one-time” download needs an atomic token redemption in the datastore. The network can still fail after redemption but before the user receives the complete file. Define the semantics explicitly: one authorization attempt, or retries during a short grace window. A backend authorization event alone cannot prove completed delivery.

9. Caching private data

  • Cache-Control: private, no-store is a sensible default for sensitive documents.
  • If caching is allowed, the cache key must include the authorization context, or the system must use correctly scoped immutable signed URLs.
  • Never let a CDN cache a private response and replay it across users.
  • Immutable versioned storage keys avoid replacement races between authorization and file open.
  • Document revocation behavior: how long issued links survive and when physical deletion occurs.

10. Log accurately: authorization is not completion

The application should log the policy decision with request ID, user/tenant, file ID, result, and token ID where relevant—never secrets or full signed URLs. NGINX access logs can record the final status, bytes sent, and request time. Correlate both sides with X-Request-Id or another correlation ID.

log_format downloads '$request_id $remote_addr $status '
                     '$body_bytes_sent $request_time $uri';

access_log /var/log/nginx/downloads.log downloads;

A 200/206 status and byte count are useful operational signals, but do not call them proof of a completed user download without a precise business definition. A client may disconnect, a proxy may buffer, or only one range may have been requested.

11. Test before opening traffic

# The internal URI must not be directly accessible
curl -i https://files.example.com/_protected/tenant-7/example.pdf
# Expected: 404

# Authorized endpoint
curl -I -H 'Authorization: Bearer ...' \
  https://files.example.com/downloads/8f2...

# Range request
curl -i -H 'Authorization: Bearer ...' \
  -H 'Range: bytes=0-1023' \
  https://files.example.com/downloads/8f2...

The matrix should include anonymous access, wrong tenant, revoked file, expired token, valid and invalid ranges, HEAD, Unicode names, missing files, symlinks, concurrent requests, and mid-transfer disconnects. Inspect the final client response, not only the raw application-server response.

12. Safe rollout and rollback

  1. Create private storage with minimal ownership and permissions.
  2. Add the internal location and run nginx -t before reload.
  3. On staging, prove direct access returns 404 and the internal redirect serves the correct file.
  4. Deploy backend generation of safe storage keys and metadata headers.
  5. Ramp traffic while watching 403/404/5xx, 206, throughput, open files, and disk latency.
  6. Keep a feature flag for the previous transfer path during migration, but never make the storage location public as a rollback.

Production checklist

  • The app checks identity, tenant, and policy before issuing an internal URI.
  • The file location is internal, and direct requests return 404.
  • The client never controls a filesystem path; the server generates storage keys.
  • Files live outside the web root with minimal read permissions and a clear symlink policy.
  • Download names reject CR/LF and are safely encoded in Content-Disposition.
  • The cache policy fits private data and prevents cross-user CDN responses.
  • Range, HEAD, Unicode, revocation, expiry, disconnect, and concurrency are tested.
  • Application and NGINX logs share a correlation ID without secrets.
  • Dashboards cover status, bytes, latency, disk/network, and missing files.

Conclusion

X-Accel-Redirect establishes a clean architectural boundary: the backend owns authorization while NGINX owns file transfer. The value is not performance alone; an internal location, server-controlled storage keys, and files outside the web root also reduce path exposure. A production implementation must address traversal, Content-Disposition, ranges, caching, token semantics, logging, and rollback—and then prove the whole flow from the client's point of view.

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.