A lightweight NGINX-based authentication proxy that protects web applications using hash-based authentication and username/password authentication.
NGINX Hash Lock sits in front of your application and provides flexible authentication options:
- Hash Authentication: Block requests unless they include
?hash=YOUR_SECRET_HASH - Username/Password Authentication: Show a login page requiring credentials
- Both Methods: Accept either hash parameter OR valid login session
- No Authentication: Optionally disable security entirely
Core Settings:
environment:
BACKEND_HOST: "your-app" # Required: Backend service hostname
BACKEND_PORT: "8080" # Required: Backend service port
LISTEN_PORT: "80" # Required: Port NGINX listens on (80 recommended for clean subdomains)Authentication Options:
# Hash-based authentication (automatically provided by CasaOS)
AUTH_HASH: $AUTH_HASH # Optional: For hash-based auth (CasaOS provides this)
# Important: Also add /?hash=$AUTH_HASH to x-casaos.index
# Username/Password authentication
USER: "admin" # Optional: Username for login page
PASSWORD: "your-secure-password" # Optional: Password for login page
SESSION_DURATION_HOURS: "720" # Optional: Session duration in hours (default: 720 = 30 days)Bypass Options:
ALLOWED_EXTENSIONS: "js,css,png,ico" # Optional: Allow static files without auth
ALLOWED_PATHS: "login,api/health" # Optional: Allow specific paths without auth
ALLOW_HASH_CONTENT_PATHS: "true" # Optional: Allow /[40-hex-char]/* paths without auth (for Stremio, etc.)Proxy Behavior (Advanced):
# These have sensible defaults - only override if needed
PROXY_BUFFERING: "off" # Default: off. Use "on" for caching/rate-limiting support
PROXY_REQUEST_BUFFERING: "off" # Default: off. Use "on" if backend needs full body before processing
PROXY_CONNECT_TIMEOUT: "300s" # Default: 300s. Time to establish backend connection
PROXY_SEND_TIMEOUT: "300s" # Default: 300s. Timeout between write operations to backend
PROXY_READ_TIMEOUT: "300s" # Default: 300s. Timeout between read operations from backend
CLIENT_MAX_BODY_SIZE: "0" # Default: 0 (unlimited). Use "10G" or "100M" to limit uploadsThe system automatically selects the authentication mode based on which environment variables are configured:
| AUTH_HASH | USER/PASSWORD | Mode | Behavior |
|---|---|---|---|
| ✅ Defined | ❌ Undefined | Hash Only | Require ?hash= parameter, show 403 page on failure |
| ❌ Undefined | ✅ Defined | Credentials Only | Show login page, require username/password, no hash option |
| ✅ Defined | ✅ Defined | Both Methods | Accept either hash parameter OR valid login session |
| ❌ Undefined | ❌ Undefined | No Authentication | Allow all requests (security disabled) |
$AUTH_HASH is automatically provided by Yundera's CasaOS - you don't need to manually configure it. However, you must:
- Include
$AUTH_HASHin the environment (CasaOS will populate it) - Add
?hash=$AUTH_HASHto the index in x-casaos metadata
Example:
environment:
AUTH_HASH: $AUTH_HASH # CasaOS provides this automatically
x-casaos:
index: /?hash=$AUTH_HASH # Important! Pass hash to URLThis ensures the Dashboard button automatically includes the authentication hash.
The NGINX Hash Lock container MUST have the same name as the app. The mesh-router routes subdomains based on container name matching the app name in docker-compose.yml.
Correct Setup:
name: myapp # App name
services:
myapp: # ← Service name matches app name
image: ghcr.io/yundera/nginx-hash-lock:latest
container_name: myapp # ← Container name matches app name
environment:
BACKEND_HOST: "myapp-backend" # ← Points to backend
...
myapp-backend: # ← Backend has different name
image: your-actual-app:latest
container_name: myapp-backend
x-casaos:
main: myapp # ← Main service is the nginx proxyWhy this matters:
- Subdomain
myapp-username.nsl.shroutes to container namedmyapp - If the backend has the app name, traffic bypasses NGINX Hash Lock entirely
- The nginx proxy must "claim" the app name for proper routing
services:
hashlock:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
AUTH_HASH: $AUTH_HASH # CasaOS provides this
BACKEND_HOST: "myapp"
BACKEND_PORT: "8080"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- myapp
myapp:
image: your-app:latest
x-casaos:
main: hashlock
index: /?hash=$AUTH_HASH # IMPORTANT: Include hash in URL
webui_port: 80CasaOS Dashboard button: Automatically opens with authentication hash
services:
hashlock:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
USER: $USER # Set in CasaOS or compose
PASSWORD: $PASSWORD # Set in CasaOS or compose
SESSION_DURATION_HOURS: "168" # 1 week
BACKEND_HOST: "myapp"
BACKEND_PORT: "8080"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- myapp
myapp:
image: your-app:latest
x-casaos:
main: hashlock
index: / # No hash needed - shows login page
webui_port: 80CasaOS Dashboard button: Opens login page → Enter credentials → 1-week session
services:
hashlock:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
AUTH_HASH: $AUTH_HASH # Option 1: CasaOS hash
USER: $USER # Option 2: Password auth
PASSWORD: $PASSWORD
SESSION_DURATION_HOURS: "720" # 30 days
BACKEND_HOST: "myapp"
BACKEND_PORT: "8080"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- myapp
myapp:
image: your-app:latest
x-casaos:
main: hashlock
index: /?hash=$AUTH_HASH # Dashboard uses hash (quick access)
webui_port: 80CasaOS Dashboard button: Opens with hash (quick access) Alternative: Visit without hash → Login page → Enter credentials → 30-day session
- With correct hash:
https://yourapp.example.com/?hash=my-secret-123→ Access granted - Without hash: Returns 403 Forbidden with custom error page
- First visit: Shows login page
- Enter credentials: Username and password validated (2-second delay on failure for anti-brute-force)
- Session created: Secure cookie with configurable expiration (default: 30 days)
- Subsequent visits: Automatic access with valid session cookie
- With hash parameter: Instant access (no login required)
- With valid session: Access granted
- Without either: Redirected to login page
Useful for CSS, JavaScript, images:
ALLOWED_EXTENSIONS: "js,css,png,ico,svg,woff,woff2"Now /styles/app.css works without a hash, but /admin still requires it.
Useful for login pages or public APIs:
ALLOWED_PATHS: "login,about,api/health,api/public"Now /login and /api/health work without a hash, but /dashboard still requires it.
Important - Reserved Paths:
/nhl-auth/is reserved for internal authentication endpoints and cannot be used in ALLOWED_PATHS/loginis reserved for the login page- All other paths are available for use in ALLOWED_PATHS
- The
/authpath is now available for your application (previously reserved)
Some applications like Stremio use 40-character hexadecimal paths for content:
/8187fed409fc90636a87a44b706ade4865e83bc9/video.mp4/bca2d44dcd7655ecfdffe81659a569d3525f0195/0
These paths are dynamically generated and the hash itself acts as the access token. To allow these paths without requiring additional authentication:
environment:
ALLOW_HASH_CONTENT_PATHS: "true"- Main site (
/,/settings, etc.) → Requires login or?hash=AUTH_HASH - Content paths (
/[40-hex-chars]/*) → Accessible if you know the content hash
This is similar to how signed URLs work on cloud storage services - the hash IS the authentication for that specific content.
services:
stremio:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
AUTH_HASH: $AUTH_HASH
USER: "admin"
PASSWORD: "stremio"
BACKEND_HOST: "stremiocommunity"
BACKEND_PORT: "8080"
LISTEN_PORT: "80"
ALLOW_HASH_CONTENT_PATHS: "true" # Required for video streaming
expose:
- 80
stremiocommunity:
image: tsaridas/stremio-docker:latestQuick access via URL hash parameter - Dashboard button includes hash automatically:
services:
yunderaterminal:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
AUTH_HASH: $AUTH_HASH # CasaOS provides this
BACKEND_HOST: "ttyd"
BACKEND_PORT: "7681"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- ttyd
ttyd:
image: tsl0922/ttyd:latest
command: ["ttyd", "--writable", "chroot", "/host", "bash"]
x-casaos:
main: yunderaterminal
index: /?hash=$AUTH_HASH # IMPORTANT: Pass hash to URL
webui_port: 80CasaOS Dashboard: Automatically opens with hash → Instant access
Session-based login with username/password:
services:
yunderaterminalpass:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
USER: $USER # Set in CasaOS
PASSWORD: $PASSWORD # Set in CasaOS
SESSION_DURATION_HOURS: "720" # 30 days
BACKEND_HOST: "ttydpass"
BACKEND_PORT: "7681"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- ttydpass
ttydpass:
image: tsl0922/ttyd:latest
command: ["ttyd", "--writable", "chroot", "/host", "bash"]
x-casaos:
main: yunderaterminalpass
index: / # No hash - show login page
webui_port: 80CasaOS Dashboard: Opens login page → Enter credentials → 30-day session
Accept BOTH hash OR password for maximum flexibility:
services:
yunderaterminalboth:
image: ghcr.io/yundera/nginx-hash-lock:latest
environment:
AUTH_HASH: $AUTH_HASH # Option 1: CasaOS hash (Dashboard)
USER: $USER # Option 2: Login page
PASSWORD: $PASSWORD
SESSION_DURATION_HOURS: "168" # 1 week
BACKEND_HOST: "ttydboth"
BACKEND_PORT: "7681"
LISTEN_PORT: "80"
expose:
- 80
depends_on:
- ttydboth
ttydboth:
image: tsl0922/ttyd:latest
command: ["ttyd", "--writable", "chroot", "/host", "bash"]
x-casaos:
main: yunderaterminalboth
index: /?hash=$AUTH_HASH # Dashboard uses hash for quick access
webui_port: 80CasaOS Dashboard: Opens with hash → Instant access Alternative: Visit without hash parameter → Login page → 1-week session
- Session-based authentication: Secure httpOnly cookies prevent XSS attacks
- Anti-brute-force protection: 2-second delay on failed login attempts
- Configurable session duration: Set
SESSION_DURATION_HOURSto control session lifetime - Automatic session cleanup: Expired sessions are automatically removed from memory
- URL parameter validation: Simple and effective for trusted environments
- No server-side state: Stateless authentication
- Hash is visible in URLs: This is simple authentication, not encryption. Use HTTPS in production.
- Use HTTPS in production: Prevents hash and cookie exposure over network
- Strong passwords: Use strong passwords for username/password authentication
- Session security: Sessions are stored in memory and cleared on container restart
- Rotate credentials: Change
AUTH_HASHorPASSWORDif compromised - Not a replacement for OAuth/SAML: Use for simple cases or as an additional protection layer
Dockerfile- Debian NGINX container with Node.jsnginx.conf- NGINX configuration template with auth_request supportentrypoint.sh- Configures authentication mode and starts services403.html- Custom error page for hash authentication failureslogin.html- Login page for username/password authentication
auth-service/app.js- Express.js authentication serviceauth-service/package.json- Node.js dependencies
The entrypoint script automatically:
- Determines authentication mode based on environment variables
- Starts the Node.js auth service if credentials are configured
- Configures hash content paths bypass if
ALLOW_HASH_CONTENT_PATHS=true - Generates appropriate NGINX configuration for the selected auth mode
- Configures optional allowed paths/extensions
- Starts NGINX with the generated configuration
No manual configuration needed - just set environment variables and run.
| Configuration | Auth Service (port 9999) | NGINX |
|---|---|---|
| Hash only | ✅ | ✅ |
| Credentials only | ✅ | ✅ |
| Both methods | ✅ | ✅ |
| No authentication | ❌ | ✅ |
Hash-Only Mode:
Request → NGINX auth_request to auth service → Check session cookie
├─ Valid session → Backend
└─ No/invalid session → Check ?hash parameter
├─ Valid hash → Create session cookie → Backend
└─ Invalid/missing → Return 403 Forbidden
Credentials-Only Mode:
Request → NGINX auth_request to auth service → Check session cookie
├─ Valid session → Backend
└─ No/invalid session → Redirect to /login → Validate credentials → Set cookie → Backend
Both Methods Mode:
Request → NGINX auth_request to auth service → Check session cookie
├─ Valid session → Backend
└─ No/invalid session → Check ?hash parameter
├─ Valid hash → Backend
└─ Invalid/missing → Redirect to /login
Hash Content Paths Mode (when ALLOW_HASH_CONTENT_PATHS=true):
Request matching /[40-hex-chars]/* pattern → Direct proxy to backend (no auth)
Other requests → Normal authentication flow
- Sessions stored in-memory (Node.js auth service)
- Automatic cleanup of expired sessions every hour
- Session IDs are cryptographically secure (32 random bytes)
- Sessions survive nginx reload but not container restart
NGINX Hash Lock has been tested with:
- Stremio - Media streaming (use
ALLOW_HASH_CONTENT_PATHS=true) - Jellyfin/Emby - Media servers with transcoding
- Plex - Media server with remote access
- qBittorrent - Download manager
- Transmission - Torrent client
- File browsers - Filebrowser, FileShelter
- Code servers - VS Code Server, code-server
- Terminal apps - ttyd, wetty, gotty
The ALLOW_HASH_CONTENT_PATHS feature is useful for:
- Media servers that use 40-character hex paths for content
- Applications where the content hash acts as an access token
- Stremio and similar streaming applications
| Feature | Status | Notes |
|---|---|---|
| Standard HTTP/1.1 apps | ✅ | Fully supported |
| WebSocket connections | ✅ | Automatic detection and upgrade |
| Video/audio streaming | ✅ | Buffering disabled by default |
| Large file uploads | ✅ | Unlimited by default |
| Server-Sent Events (SSE) | ✅ | Proper headers configured |
| Long-polling requests | ✅ | 5-minute timeouts |
| REST APIs | ✅ | All methods supported |
| Feature | Status | Reason |
|---|---|---|
| gRPC | ❌ | Requires grpc_pass directive and HTTP/2 - fundamentally different from HTTP proxying |
| HTTP/2 to backend | ❌ | Uses HTTP/1.1 for backend connections (sufficient for 99% of apps) |
| Headers with underscores | Ignored by default (nginx default behavior) |
Note on gRPC: Applications using gRPC (some CI/CD tools, Kubernetes services) cannot be proxied through NGINX Hash Lock. gRPC requires a completely different nginx configuration using grpc_pass instead of proxy_pass.
NGINX Hash Lock is designed to work with any application out of the box. The defaults prioritize compatibility over performance.
| Variable | Default | Description |
|---|---|---|
PROXY_BUFFERING |
off |
Response buffering. off = streaming-friendly, on = better for caching |
PROXY_REQUEST_BUFFERING |
off |
Request buffering. off = large uploads work, on = backend gets full body first |
PROXY_CONNECT_TIMEOUT |
300s |
Time allowed to establish connection with backend |
PROXY_SEND_TIMEOUT |
300s |
Timeout between successive write operations to backend |
PROXY_READ_TIMEOUT |
300s |
Timeout between successive read operations from backend |
CLIENT_MAX_BODY_SIZE |
0 |
Maximum upload size. 0 = unlimited, or use 10G, 100M, etc. |
Most apps need no configuration - the defaults handle:
- Video/audio streaming (Stremio, Jellyfin, Plex)
- Large file uploads (ConvertX, file managers)
- WebSocket connections (terminals, real-time apps)
- Server-Sent Events (SSE)
- Long-polling requests
Override only if:
| Scenario | Setting |
|---|---|
| Need nginx-level caching | PROXY_BUFFERING=on |
| Need nginx rate-limiting | PROXY_BUFFERING=on |
| Backend requires full request before processing | PROXY_REQUEST_BUFFERING=on |
| Want to limit upload sizes | CLIENT_MAX_BODY_SIZE=10G |
| Very long operations (>5 min) | PROXY_READ_TIMEOUT=3600s |
These issues are handled without configuration:
| Feature | Implementation |
|---|---|
| WebSocket support | Correct Connection header via nginx map directive |
| Forwarded headers | X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port |
| SSE support | X-Accel-Buffering: no header |
| Backend redirects | Proper redirect rewriting |
The Docker image is automatically built and published to GitHub Container Registry via GitHub Actions on every push to main.
Image location: ghcr.io/yundera/nginx-hash-lock:latest
For manual builds (development only):
docker build -t krizcold/nginxhashlock:dev .
docker push krizcold/nginxhashlock:dev