Security Guide
Overview
OpenWatchParty includes several security features to protect your installation:
- JWT Authentication - Token-based access control
- CORS Protection - Origin validation
- Rate Limiting - Abuse prevention
- Message Validation - Input sanitization
Authentication
How It Works
- User authenticates with Jellyfin
- Client requests JWT token from plugin
- Client sends token to session server
- Server validates token before allowing actions
Enabling Authentication
1. Generate a Secret
# Generate a secure 32+ character secret
openssl rand -base64 32
# Example output: K7xR9mPqN2wLhVbE4cT8fY0jU5sA3dG1
2. Configure Plugin
- Go to Dashboard > Plugins > OpenWatchParty
- Enter the secret in JWT Secret
- Click Save
3. Configure Session Server
# docker-compose.yml
services:
session-server:
environment:
- JWT_SECRET=K7xR9mPqN2wLhVbE4cT8fY0jU5sA3dG1
Both must use the same secret.
Token Structure
Generated tokens contain:
| Claim | Description |
|---|---|
sub |
Jellyfin user ID |
name |
User display name |
aud |
Audience (configurable) |
iss |
Issuer (configurable) |
iat |
Issued at timestamp |
exp |
Expiration timestamp |
Token Lifetime
Default: 1 hour (3600 seconds)
Configurable in plugin settings:
- Minimum: 60 seconds
- Maximum: 86400 seconds (24 hours)
CORS (Cross-Origin Resource Sharing)
Why It Matters
CORS prevents unauthorized websites from connecting to your session server.
Configuration
environment:
# Single origin (recommended)
- ALLOWED_ORIGINS=https://jellyfin.example.com
# Multiple origins
- ALLOWED_ORIGINS=https://jellyfin.example.com,https://jellyfin2.example.com
# Wildcard (NOT recommended for production)
- ALLOWED_ORIGINS=*
Security Warning
Using * logs a warning:
SECURITY: Wildcard origin (*) configured - ALL origins allowed!
This allows any website to connect to your session server.
Rate Limiting
Token Endpoint (Plugin)
- Limit: 30 tokens per minute per user
- Purpose: Prevents token abuse
- Scope: Per authenticated Jellyfin user
WebSocket Messages (Server)
- Limit: 30 messages per second per client
- Purpose: Prevents message flooding
- Scope: Per WebSocket connection (client UUID)
Message Size
- Limit: 64 KB per message
- Purpose: Prevents memory exhaustion attacks
Important: Rate Limiting is Per-Client, Not Per-IP
The session server rate limits by client UUID (WebSocket connection), not by IP address. This means:
- An attacker could bypass rate limits by opening multiple WebSocket connections
- Each new connection gets a fresh rate limit quota
Why this design?
- The server doesn’t have direct access to client IPs (often behind reverse proxy)
- Per-connection limiting is simpler and works in most scenarios
- Most abuse cases are prevented by JWT authentication
For production deployments, implement IP-based rate limiting at the reverse proxy level:
# nginx example
limit_req_zone $binary_remote_addr zone=ws_limit:10m rate=10r/s;
location /ws {
limit_req zone=ws_limit burst=20 nodelay;
proxy_pass http://session-server:3000;
# ... websocket config
}
# Traefik example
http:
middlewares:
rate-limit:
rateLimit:
average: 10
burst: 20
HTTPS/WSS
Why Use Encrypted Connections
- Protects JWT tokens from interception
- Prevents man-in-the-middle attacks
- Required for production use
Setup
- Configure reverse proxy with SSL (see Deployment)
- Update Session Server URL to use
wss://:wss://jellyfin.example.com/ws
Certificate Validation
The session server validates certificates by default. For self-signed certificates (development only), you may need to disable validation in the client or add the CA to the trust store.
Input Validation
URL Validation
Image URLs are validated to prevent XSS:
// Only allows http(s) URLs
if (imageUrl && /^https?:\/\//i.test(imageUrl)) {
// Safe to use
}
This blocks:
javascript:URLsdata:URLs- Other potentially malicious schemes
Message Validation
The server validates:
- Message type (must be known type)
- Room existence (for room operations)
- Host permissions (for playback control)
- Payload structure
Security Best Practices
Production Checklist
- JWT authentication enabled
- Strong JWT secret (32+ characters)
- CORS restricted to specific origins
- HTTPS enabled (via reverse proxy)
- Session server not directly exposed to internet
- Regular updates applied
- Logs monitored for suspicious activity
Network Security
services:
session-server:
# Only expose to reverse proxy
expose:
- "3000"
# Don't publish port externally
# ports:
# - "3000:3000" # BAD
networks:
- internal
Secret Management
DO:
- Use environment variables for secrets
- Use
.envfiles (not committed to git) - Rotate secrets periodically
- Use different secrets per environment
DON’T:
- Hardcode secrets in configuration files
- Commit secrets to version control
- Share secrets across environments
- Use short or predictable secrets
Logging
Log security-relevant events:
environment:
- LOG_LEVEL=warn # Logs security warnings
Security warnings logged:
- Wildcard CORS configuration
- Invalid token attempts
- Rate limit violations
- Oversized messages
Threat Model
Threats Addressed
| Threat | Mitigation |
|---|---|
| Unauthorized access | JWT authentication |
| Token theft | Short expiration, HTTPS |
| Cross-site attacks | CORS validation, URL sanitization |
| Denial of service | Rate limiting, message size limits |
| Man-in-the-middle | HTTPS/WSS encryption |
Known Limitations
| Limitation | Status |
|---|---|
| No room passwords | Planned |
| No user permissions (democratic mode) | Planned |
| Ephemeral sessions | By design |
| Single secret for all users | By design |
| Rate limiting per client, not IP | By design (use reverse proxy) |
| No token revocation | By design (short TTL, rotate secret) |
What JWT Authentication Does NOT Protect
It’s important to understand the scope of JWT authentication. While it verifies user identity, it has limitations:
Not Protected by JWT
| Scenario | Current Behavior | Mitigation |
|---|---|---|
| Room creation | Any authenticated user can create rooms | By design - all Jellyfin users are trusted |
| Room joining | Any authenticated user can join any room | Planned: room passwords |
| Room enumeration | All users see all active rooms | By design - rooms are public within your Jellyfin instance |
| Token revocation | Tokens valid until expiration | Rotate JWT secret to invalidate all tokens |
Token Lifecycle
- Tokens cannot be individually revoked - Once issued, a token is valid until it expires
- Secret rotation invalidates ALL tokens - Changing the JWT secret requires all users to re-authenticate
- No refresh tokens - Users get a new token on each session, not a refresh mechanism
Trust Model
JWT authentication operates on a trust boundary at the Jellyfin level:
Internet → [Jellyfin Auth] → Trusted Zone → [OpenWatchParty]
↑ ↑
Auth boundary All users equally trusted
Implications:
- If a user can log into Jellyfin, they can use OpenWatchParty
- There’s no additional access control layer within OpenWatchParty
- Restrict Jellyfin access to control who can use watch parties
Recommendations
- For private instances - JWT provides sufficient protection
- For shared/public instances - Wait for room passwords feature or restrict Jellyfin user creation
- For sensitive content - Use Jellyfin’s library permissions to control media access
Incident Response
If Secret is Compromised
- Immediately change the JWT secret on both plugin and server
- Restart all services
- All existing tokens become invalid
- Users must re-authenticate
If Suspicious Activity Detected
- Check logs for details
- Consider temporarily disabling the service
- Review CORS and authentication settings
- Update to latest version
Container Security
Base Image
The session server uses Alpine Linux as its base image for minimal attack surface:
| Image | Size | CVEs |
|---|---|---|
debian:bookworm-slim |
~100MB | 30+ |
alpine:3.21 |
~26MB | ~6 (low severity) |
Security Scanning
Container images are automatically scanned on every push:
- Trivy: Scans for CVEs in OS packages and dependencies
- Results: Uploaded to GitHub Security tab
- Severity filter: CRITICAL and HIGH vulnerabilities are flagged
Current Security Posture
The Alpine-based image has minimal remaining vulnerabilities:
| CVE | Component | Severity | Impact |
|---|---|---|---|
| CVE-2024-58251 | BusyBox netstat | Warning | Not used by application |
| CVE-2025-46394 | BusyBox tar | Note | Not used by application |
These vulnerabilities:
- Affect tools not used by the application (tar, netstat)
- Require local access to exploit
- Are low severity (warning/note, not critical/high)
Hardening Recommendations
For maximum security in sensitive environments:
# Option 1: Distroless (no shell, no package manager)
FROM gcr.io/distroless/static
# Requires custom healthcheck binary
# Option 2: Scratch (empty image)
FROM scratch
# Requires static binary compilation
Runtime Security
The container runs with:
- Non-root user:
appuser(UID 1000) - Read-only filesystem: Mount volumes as needed
- Resource limits: CPU and memory limits in docker-compose
- Health checks: Automatic container restart on failure
# docker-compose.yml security settings
services:
session-server:
user: "1000:1000"
read_only: true
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
Security Updates
Stay informed about security updates:
- Watch the GitHub repository
- Check release notes for security fixes
- Update promptly when security patches are available
- Monitor the Security tab for vulnerability alerts
Reporting Security Issues
If you discover a security vulnerability:
- Do not open a public issue
- Email the maintainer directly
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
Next Steps
- Deployment - Production deployment
- Monitoring - Monitor for issues
- Troubleshooting - Common problems