A lightweight NGINX-based authentication proxy that protects web applications using hash-based authentication and username/password authentication.
AppShield 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 authentication — MACHINE / API access (CasaOS provides this value).
# The secret can be presented three ways: ?hash=<value> in the URL, an
# "Authorization: Bearer <value>" header, or HTTP Basic ("-u any:<value>").
AUTH_HASH: $AUTH_HASH # Optional: enables machine/API auth (CasaOS provides this)
# Important: Also add /?hash=$AUTH_HASH to x-casaos.index
# How AUTH_HASH is sourced — AUTH_HASH_MODE: managed | env | off (default: off)
# off No hash-based machine auth; any incoming AUTH_HASH is ignored. (default)
# env Use AUTH_HASH from the environment as-is. The caller owns the value
# and its lifecycle — e.g. interpolated from a persistent .env so it
# survives uninstall/reinstall (see the Beacon app for the pattern).
# managed AppShield owns the token: it generates a 128-hex secret once into
# AUTH_HASH_FILE (default /data/auth_hash) on a persistent volume and
# reuses it on every restart/reinstall. Immune to platform-side
# rotation. The incoming AUTH_HASH env is never read. Mount a volume
# at /data and surface the token via the app itself — a managed token
# is NOT shown through CasaOS tips.
AUTH_HASH_MODE: "off" # Optional: source/lifecycle of AUTH_HASH
AUTH_HASH_FILE: "/data/auth_hash" # Optional: managed-mode token path (default shown)
# 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)
# OIDC authentication (registrar-driven SSO)
OIDC_REGISTRAR_URL: "http://auth-registrar:9092" # Setting this enables OIDC mode. The sidecar
# self-registers with the registrar at first
# login, gets back client_id + client_secret +
# issuer_url, and runs authorization_code + PKCE.
# No per-app secrets to configure.
# Must be reachable on the pcs network.
# CasaOS-credential API auth (MACHINE, real per-user identity, no redirect)
CREDENTIAL_VALIDATE_URL: "" # When set, an "Authorization: Basic <user:pass>"
# or "Bearer <token>" that isn't the static AUTH_HASH
# is verified here — point it at the CasaOS bridge's
# INTERNAL /validate port (http://casaos-oidc-bridge:8090/validate),
# which checks it against CasaOS. Lets API clients use
# `-u <casaos-user>:<casaos-pass>`. No browser redirect.
# The validator is pcs-network-only, so no secret is needed.
CREDENTIAL_CACHE_TTL_SECONDS: "60" # Optional: cache successful validations (default 60s)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 uploadsAppShield separates two audiences, configured independently:
- Humans pick one interactive method (strict either/or): Web login (a
USER/PASSWORDform) or SSO (OIDC redirect to the PCS identity provider). They are mutually exclusive — ifOIDC_REGISTRAR_URLis set, it wins. - Machines / API clients use the hash (
AUTH_HASH) — a non-interactive secret, and an addition: set it alongside either human method (or on its own) and it composes.
The three distinct mechanisms:
| Mechanism | Audience | How the client presents it | Interactive? |
|---|---|---|---|
| Hash | Machine / API | ?hash=<secret> URL param or Authorization: Bearer <secret> or HTTP Basic (-u any:<secret>) |
No |
| Web login | Human | username/password form → session cookie | Yes |
| SSO (OIDC) | Human | redirect to the identity provider (Dex → CasaOS), authorization_code + PKCE → session cookie | Yes |
Hash = "true" HTTP Basic auth for machines. In machine-only deployments the gate answers an unauthenticated request with
401 WWW-Authenticate: Basic, so standard tooling (curl -u, HTTP client libraries) authenticates out of the box. The hash is the credential — it is checked againstAUTH_HASH, neverUSER/PASSWORD.
CasaOS identity over the API. Set
CREDENTIAL_VALIDATE_URL(→ the CasaOS bridge's/validate) so machine clients can authenticate with their real CasaOS credentials —-u <casaos-user>:<casaos-pass>(HTTP Basic) or a CasaOSBearertoken — instead of, or alongside, a sharedAUTH_HASH. No browser redirect: the gate verifies the credential out-of-band via the bridge (which checks it against CasaOS), so an API call carries a real per-user identity. Successful checks are cached forCREDENTIAL_CACHE_TTL_SECONDS. This is a machine mechanism — humans still use Web login or SSO. (OIDC's password grant can't reach CasaOS through Dex, which is why this goes via the bridge, not Dex.)
The mode is selected automatically from which variables are set:
| OIDC_REGISTRAR_URL | AUTH_HASH | USER/PASSWORD | Mode | Behavior |
|---|---|---|---|---|
| ✅ Set | (any) | (any) | OIDC | Self-registers with the PCS's identity provider, runs authorization_code+PKCE, drops a session cookie |
| ❌ | ✅ Defined | ❌ Undefined | Hash Only | Machine/API: ?hash=, Bearer, or HTTP Basic. 401 WWW-Authenticate: Basic on failure |
| ❌ | ❌ Undefined | ✅ Defined | Credentials Only | Show login page, require username/password, no hash option |
| ❌ | ✅ Defined | ✅ Defined | Both Methods | Machine hash (?hash= / Bearer / Basic) or human web login |
| ❌ | ❌ Undefined | ❌ Undefined | No Authentication | Allow all requests (security disabled) |
OIDC + hash compose. Set
OIDC_REGISTRAR_URLandAUTH_HASHtogether to serve both audiences from one gate: interactive users get the SSO redirect, while non-interactive API / non-human clients pass?hash=YOUR_SECRET_HASHand bypass the redirect. The auth service honours a valid hash in any mode. (StaticUSER/PASSWORDcredential mode does not compose with OIDC — use hash for machine access alongside OIDC.)
$AUTH_HASH is automatically provided by 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 AppShield 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/appshield: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.example.comroutes to container namedmyapp - If the backend has the app name, traffic bypasses AppShield entirely
- The nginx proxy must "claim" the app name for proper routing
OIDC mode enforces this too. When OIDC is enabled, AppShield self-registers with the registrar, which derives the caller's identity from its container name (Docker reverse-DNS on the network) and only authorizes redirect URIs whose hostname first label equals that name (or starts with
<name>-). So the gate container must be named after the app's subdomain — e.g. formyapp-username.example.com, the AppShield container must be namedmyapp. A sidecar namedmyapp-proxywill be rejected (redirect URI hostname ... must start with "myapp-proxy-"). Settinghostname:alone is not enough; it's the container name that the registrar reads.
services:
hashlock:
image: ghcr.io/yundera/appshield: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/appshield: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/appshield: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
In OIDC mode the gate container must be named after the app's subdomain (see Container Naming above). Here the app is
myapp, so the AppShield container ismyappand the real app ismyapp-backend.
services:
myapp:
image: ghcr.io/yundera/appshield:latest
container_name: myapp # ← must match the app subdomain (= OIDC identity)
environment:
OIDC_REGISTRAR_URL: "http://auth-registrar:9092" # Presence enables OIDC mode
BACKEND_HOST: "myapp-backend"
BACKEND_PORT: "8080"
LISTEN_PORT: "80"
expose:
- 80
networks:
- pcs # Required: must be on the pcs network to reach the registrar
depends_on:
- myapp-backend
myapp-backend:
image: your-app:latest
container_name: myapp-backend
networks:
pcs:
external: true
name: pcs
x-casaos:
main: myapp
index: /
webui_port: 80What happens at first user hit:
- User hits
https://myapp-alice.example.com/→ nginx runsauth_request→ 401 (no cookie). - Nginx redirects to
/nhl-auth/oidc/login, which POSTs to$OIDC_REGISTRAR_URL/register(e.g.http://auth-registrar:9092/register) with its callback URL. - The registrar identifies the caller as container
myappvia PTR on the pcs network, registers an OIDC client with the SSO provider, and returns{client_id, client_secret, issuer_url}. - The sidecar initializes
openid-client, kicks off authorization_code + PKCE (S256), and redirects the browser to the SSO provider's/authorize. - The user logs in with the SSO provider → bounced back to
/nhl-auth/oidc/callback→ session cookie set → redirected to the original URL.
Subsequent boots: the registrar is idempotent — the same client and secret come back, so the OIDC client is reused.
- 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/appshield: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/appshield: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/appshield: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/appshield: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
OIDC Mode:
Request → NGINX auth_request to auth service → Check session cookie
├─ Valid session (has oidcSub) → Backend
└─ No/invalid session → Redirect to /nhl-auth/oidc/login
├─ First call: POST to auth-registrar → cache client creds in memory
└─ Redirect to SSO provider /authorize (PKCE S256)
→ SSO login → /nhl-auth/oidc/callback
→ Exchange code for tokens → Mint session with oidcSub → Backend
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
AppShield 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 AppShield. gRPC requires a completely different nginx configuration using grpc_pass instead of proxy_pass.
AppShield 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/appshield:latest
For manual builds (development only):
docker build -t krizcold/appshield:dev .
docker push krizcold/appshield:dev