Security Guide
Report vulnerabilities privately through the repository Security Policy, never through a public issue.
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
- Threat Model and Abuse Limits - see Threat Model below
Authentication
JWT Modes and Key Rotation
Manual installations can use hs256 (a shared secret), hybrid (shared-secret HS256 plus trusted RS256 during migration), or asymmetric (trusted RS256 only). The plugin signs tokens with the configured shared secret or its active RSA key; the session server must be configured to accept the matching key type, audience and issuer. In asymmetric mode, the plugin keeps its RSA private key in Jellyfin’s plugin data and the server stores only the public key. owpctl setup defaults to hybrid; owpctl pair registers the plugin public key and switches the managed server to asymmetric mode. Back up the trust store with the managed configuration and protect it from unauthorized writes.
For a managed HS256 or hybrid install, rotate the shared secret on both sides with owpctl configure --rotate-jwt-secret --yes --api-token-file <protected-file>. Rotation restarts the managed server and invalidates outstanding HS256 tokens and invite tickets. In asymmetric mode, revoke a compromised key with owpctl trust --store <trust-store> revoke --kid <key-id>. The current owpctl pair command reads the plugin’s existing signing key; it does not rotate that key. Back up the plugin configuration, secrets and trust store before changing keys. Invite links require a shared secret and are unavailable in asymmetric-only mode.
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
The steps below configure shared-secret HS256 authentication. For hybrid or asymmetric mode, see JWT Modes and Key Rotation and use owpctl pair for a managed installation.
1. Generate a Secret
# Generate a secure 32+ character secret
openssl rand -base64 32
# Store the output in your secret manager; never commit it or paste it into a document
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=<generated-base64-value>
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
The server checks browser Origin values against this allowlist before upgrading a WebSocket. This is a browser-origin filter, not authentication: non-browser clients can forge Origin, so production must also require JWT authentication.
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 removes the browser-origin filter. JWT authentication is still required because non-browser clients can forge the Origin header.
Rate Limiting and Connection Limits
Token Endpoint (Plugin)
- Limit: 30 tokens per minute per Jellyfin user
- Purpose: Limits token issuance to authenticated users and prevents token abuse
- Scope: Per authenticated Jellyfin user, enforced by
MaxTokensPerMinuteinsrc/plugins/jellyfin/OpenWatchParty/Controllers/OpenWatchPartyController.cswith the window logic insrc/plugins/jellyfin/OpenWatchParty/TokenRateLimiter.cs(HTTP429when exceeded) - Tests:
SequentialRequestsStopExactlyAtLimitandParallelBurstNeverExceedsLimitinsrc/plugins/jellyfin/OpenWatchParty.Tests/TokenRateLimiterTests.cs
WebSocket Messages (Server)
- Limit: 30 messages per 1000 ms window per connection
- Purpose: Prevents message flooding
- Scope: Per WebSocket connection, counted by
update_rate_limitinsrc/server/src/ws/dispatch.rsusingRATE_LIMIT_MESSAGESandRATE_LIMIT_WINDOW_MSfromsrc/server/src/ws/constants.rs. A connection that exceeds the window is closed with WebSocket policy code1008. - Tests:
check_rate_limit_under,check_rate_limit_at_limitandrate_limit_violation_is_terminalinsrc/server/src/ws/dispatch.rs
WebSocket Connections (Server)
- Global limit: 256 concurrent connections (
MAX_CONNECTIONS) - Per-IP limit: 32 concurrent connections per effective client IP (
MAX_CONNECTIONS_PER_IP) - Purpose: Prevents connection exhaustion and bounds the work a single source can create before authenticating
- Enforcement:
ConnectionLimiterinsrc/server/src/routes.rs; an upgrade over either limit is rejected with HTTP429 - Tests:
connection_limits_are_global_per_ip_and_released_on_dropandforwarded_for_requires_a_trusted_proxyinsrc/server/src/routes.rs
Message Size
- Limit: 64 KiB per WebSocket frame and per assembled message
- Purpose: Bounds individual frames and messages; this is not a process-wide memory limit or proof against every memory-exhaustion condition
- Enforcement:
MAX_FRAME_SIZEandMAX_MESSAGE_SIZEinsrc/server/src/ws/constants.rs, applied to the connection insrc/server/src/routes.rsand re-checked insrc/server/src/ws/dispatch.rs - Test:
transport_limits_are_fixed_at_sixty_four_kibibytesinsrc/server/src/routes.rs
Published session-server advisory
GHSA-fjpr-3g28-27r9 affects session-server releases >=0.1.0,<0.7.0. Before 0.7.0, an unauthenticated client could send oversized application-level pings that were reflected into queued pong responses. Version 0.7.0 fixes this by returning a bounded response containing only a validated clock-sync timestamp. The regression tests measure serialized queued-pong response sizes; there is no demonstrated RSS increase or out-of-memory incident. Upgrade the session server to 0.7.0 or newer; changing only the Jellyfin plugin does not install this fix.
Playback Command Cooldown (Server)
- Limit: 2000 ms cooldown after an accepted playback command (
COMMAND_COOLDOWN_MS) - Purpose: Prevents command spam and rapid state flapping
- Scope: Per room, enforced in
src/server/src/ws/handlers/playback.rs - Tests:
should_process_state_update_during_cooldownandcooldown_and_interval_ignore_wall_clock_rollbackinsrc/server/src/ws/handlers/playback.rs
Other Resource Limits
| Limit | Value | Code and tests |
|---|---|---|
| Clients per room | 20 (MAX_CLIENTS_PER_ROOM) |
Enforced in src/server/src/ws/handlers/join.rs (ROOM_FULL); tests full_destination_preserves_previous_membership and full_room_returns_explicit_error |
| Queue items per room | 20 (MAX_QUEUE_ITEMS) |
src/server/src/room/queue.rs and src/server/src/ws/handlers/queue.rs; queue bounds tests in the latter |
| JWT authentication deadline | 10 s (AUTH_TIMEOUT_SECONDS) |
Scheduled in src/server/src/ws/connection.rs, configured in src/server/src/routes.rs; test unauthenticated_jwt_connection_times_out_and_is_cleaned_up |
| Name length | 100 characters (MAX_NAME_LENGTH) |
src/server/src/ws/validation.rs; tests test_is_valid_name_invalid and test_sanitize_name_truncation. Mirrored in the browser by MAX_NAME_LENGTH in src/clients/jellyfin-web/ws/validation.js |
| Chat message length | 500 characters (MAX_CHAT_MESSAGE_LENGTH) |
Enforced in src/server/src/ws/handlers/chat.rs; tests validate_chat_too_long and validate_chat_counts_characters_not_bytes. Mirrored in the browser by MAX_CHAT_LENGTH in src/clients/jellyfin-web/ws/validation.js |
Message Rate Limiting is Per Connection, Not Per IP
Message rate limiting is tracked per WebSocket connection. A client can open additional connections and gets a fresh message budget on each one.
Why this design?
- The message limiter stays local to one connection: no shared counter, no cross-connection contention, and no cleanup of idle per-IP buckets.
- Unauthenticated connections cannot use rooms, chat or playback, and they are closed after the authentication deadline.
- The per-IP connection limit caps how many simultaneous message budgets one effective IP can hold. It does not by itself cap aggregate messages per second: with the defaults, 32 connections times 30 messages per second is a ceiling of 960 messages per second for one IP.
- Per-IP message rate limiting is still explicitly out of scope; see Per-IP Limits: Verdict.
For production deployments, add request-rate limiting at the reverse proxy (on top of the server’s connection and message limits):
# 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
}
This rate-limit excerpt covers /ws only. The proxy must also route /invite to the session server; see Reverse Proxy Configuration for complete examples.
# 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 browser validates the TLS certificate for a wss:// connection. Use a certificate trusted by each participant’s browser; do not disable certificate validation. For private deployments, install your private CA on every client device.
Input Validation
Session-server URL Validation
The client accepts an absolute ws:// or wss:// URL with a hostname. It rejects credentials, query strings and fragments, requires wss:// from an HTTPS Jellyfin page, and asks for confirmation before saving a different host or effective port. The client repeats destination checks on the plugin’s token response and immediately before opening the WebSocket. See src/clients/jellyfin-web/utils/url.js and Configuration.
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 configured and verified for the selected mode
- For HS256 or hybrid, a strong secret generated with
openssl rand -base64 32is configured on both sides - 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
This model assumes production settings: JWT authentication enabled (JWT_SECRET or the asymmetric trust store), TLS terminated at a reverse proxy, and the session server reachable only through that proxy. Insecure development mode (ALLOW_INSECURE_NO_AUTH) removes the authentication boundary entirely and must never be exposed to untrusted users.
Assets
| Asset | Why it matters | Where it lives |
|---|---|---|
| Jellyfin identity | Maps a connection to a Jellyfin user and decides who may create, join or control a room | Jellyfin user database; asserted to the session server through plugin-signed JWTs |
| Session JWTs | Bearer credential for the session server; anyone holding a valid one can act as that user until it expires | Signed by the plugin, held by the browser, validated in src/server/src/auth.rs |
| Room state | Participants, host, playback position, scheduled commands, queue items and countdown | In memory in the session server; never persisted |
| Media access | Ability to play the Jellyfin library items a room is watching | Jellyfin’s own authorization and stream URLs; the session server never proxies or stores media credentials |
Actors
| Actor | Capability | Assumed hostile? |
|---|---|---|
| Anonymous visitor | Can open a WebSocket and send messages before authenticating | Yes |
| Authenticated guest | Holds a valid JWT for its own Jellyfin identity; can create or join rooms and chat | Yes, once inside a room |
| Hostile room member | Authenticated guest that forges event envelopes, spams commands or attempts host-only actions | Yes |
| Compromised token holder | Attacker in possession of a stolen, unexpired JWT | Yes |
| Misconfigured proxy or CDN | Reverse proxy that forwards spoofed X-Forwarded-For, allows the wrong origins, buffers or caches WebSocket traffic, or exposes port 3000 directly |
Configuration error, not an attacker |
Trust Boundaries
| Boundary | What crosses it | Controls |
|---|---|---|
| Browser (Jellyfin Web origin) to session server WebSocket | WebSocket upgrade with an Origin header |
Exact-match origin allowlist (ALLOWED_ORIGINS) checked before upgrade in src/server/src/routes.rs; JWT authentication for every room, chat and playback action |
| Network to session server HTTP/WS ingress | TCP connections, WebSocket frames | Global and per-IP connection caps, 64 KiB frame limit, 10 s authentication deadline, 30 messages/1000 ms window, TRUSTED_PROXIES-gated X-Forwarded-For |
| Browser/Jellyfin UI to plugin API and configuration | Plugin settings, diagnostics, client script, and per-user session JWT | Jellyfin restricts plugin configuration to administrators; diagnostics require administrator elevation and the custom diagnostics responses omit secret values. The standard authenticated administrator configuration GET returns the full plugin configuration, including JwtSecret, which the admin page loads into its password field. Treat administrator API credentials and configuration responses as secret-bearing, and never log or expose them. The public client script and ordinary viewing-client/token endpoint responses do not include the secret; token issuance requires Jellyfin authentication and is rate limited per user. |
| Browser to Jellyfin media API | Playback and library requests | Jellyfin library permissions; OpenWatchParty does not proxy media or store Jellyfin credentials |
STRIDE Threats
| STRIDE | Threat | Mitigation | Code or test |
|---|---|---|---|
| Spoofing | Unauthenticated clients connect and try room, chat or playback actions | JWT validation gates every action; the room list is withheld until authentication; unauthenticated sockets are closed after the authentication deadline | is_authenticated in src/server/src/ws/dispatch.rs, handle_auth in src/server/src/ws/handlers/auth.rs, and the session loop in src/server/src/ws/connection.rs; test unauthenticated_jwt_connection_times_out_and_is_cleaned_up |
| Spoofing, Tampering | Hostile room members forge envelope fields (client, room, server_ts) or send host-only playback commands |
The server rebuilds envelope fields from the connection identity before broadcasting, and accepts only play and pause from non-hosts (seeking and state updates are host only) |
Tests forged_envelope_fields_are_replaced_in_broadcast and playback_rejects_non_member_and_non_host in src/server/src/ws/handlers/playback.rs; ROOM_FULL coverage in src/server/src/ws/handlers/join.rs |
| Spoofing, Information disclosure | Token theft or replay | Tokens are bearer credentials with a short lifetime (default 3600 s, allowed range 60-86400 s) and are only useful over TLS; exp is enforced at the exact second with zero leeway; rotating the JWT secret or revoking the signing key invalidates outstanding tokens. An attacker who steals an unexpired token can replay it until it expires: there is no per-token revocation. |
JWT_EXPIRATION_LEEWAY_SECONDS in src/server/src/auth.rs; session expiration handling in src/server/src/ws/connection.rs; plugin TokenTtlSeconds in OpenWatchPartyController.cs; test authenticated_session_expires_without_inbound_traffic_and_is_cleaned_up |
| Denial of service | Message flooding | 30 messages per 1000 ms per connection; violations receive RATE_LIMITED and are closed with policy code 1008 |
update_rate_limit in src/server/src/ws/dispatch.rs; tests check_rate_limit_at_limit and rate_limit_violation_is_terminal |
| Denial of service | Oversized frames or messages | 64 KiB limits for both individual frames and assembled messages, applied at the transport before assembly, with an application-level MESSAGE_TOO_LARGE check as a second line of defense |
MAX_FRAME_SIZE and MAX_MESSAGE_SIZE in src/server/src/ws/constants.rs; src/server/src/routes.rs; test transport_limits_are_fixed_at_sixty_four_kibibytes |
| Spoofing, Tampering | Cross-origin WebSocket connections | The Origin header must exactly match ALLOWED_ORIGINS before the upgrade; wildcard origin logs a warning and disables this control. Non-browser clients can forge Origin, so JWT authentication remains the real control. |
get_allowed_origins and is_origin_allowed in src/server/src/routes.rs; tests is_origin_allowed_exact_match, is_origin_allowed_no_match and is_origin_allowed_wildcard |
| Information disclosure, Denial of service | Proxy or CDN misconfiguration | X-Forwarded-For is honored only when the direct peer belongs to TRUSTED_PROXIES, and the selected address is the first untrusted hop counting from the right; connection caps still apply. Deployments must terminate TLS correctly, avoid caching or buffering WebSockets, and never expose port 3000 directly. |
parse_trusted_proxies and client_ip in src/server/src/routes.rs; test forwarded_for_requires_a_trusted_proxy; Configuration and Deployment |
| Repudiation | No durable audit trail: security-relevant events are logs only | Rejections and violations are logged at warn level; room state is ephemeral by design. Operators who need durable attribution must collect container logs. | src/server/src/ws/dispatch.rs, src/server/src/routes.rs; Monitoring |
Every control above is implemented in the referenced code, and the authentication, rate limiting, size limiting, origin checking and proxy-trust behaviours each have the cited tests. The repudiation row records a known gap rather than a control: the service emits logs, not an audit trail. The properties of the authentication boundary are covered by authenticated_session_is_valid_before_its_expiration, authenticated_session_expires_without_inbound_traffic_and_is_cleaned_up and valid_refresh_rearms_the_session_expiration in src/server/src/routes.rs.
Abuse Limits Inventory
| Limit | Value | Code reference | Test reference |
|---|---|---|---|
| Global concurrent connections | 256 (MAX_CONNECTIONS) |
src/server/src/routes.rs (ConnectionLimiter) |
connection_limits_are_global_per_ip_and_released_on_drop |
| Concurrent connections per effective IP | 32 (MAX_CONNECTIONS_PER_IP) |
src/server/src/routes.rs (ConnectionLimiter, client_ip) |
connection_limits_are_global_per_ip_and_released_on_drop, an_ip_keeps_its_entry_and_its_limit_while_a_connection_is_open, concurrent_connections_from_one_ip_keep_its_limit_and_leave_no_entry, the_last_connection_of_an_ip_removes_its_entry_while_its_semaphore_is_referenced, forwarded_for_requires_a_trusted_proxy |
| WebSocket frame size | 64 KiB (MAX_FRAME_SIZE) |
src/server/src/ws/constants.rs, applied in src/server/src/routes.rs |
transport_limits_are_fixed_at_sixty_four_kibibytes |
| Assembled message size | 64 KiB (MAX_MESSAGE_SIZE) |
src/server/src/ws/constants.rs, re-checked in src/server/src/ws/dispatch.rs |
transport_limits_are_fixed_at_sixty_four_kibibytes |
| WebSocket message rate | 30 per 1000 ms per connection (RATE_LIMIT_MESSAGES, RATE_LIMIT_WINDOW_MS) |
update_rate_limit in src/server/src/ws/dispatch.rs |
check_rate_limit_under, check_rate_limit_at_limit, rate_limit_violation_is_terminal |
| Playback command cooldown | 2000 ms (COMMAND_COOLDOWN_MS) |
src/server/src/ws/constants.rs, src/server/src/ws/handlers/playback.rs |
should_process_state_update_during_cooldown, cooldown_and_interval_ignore_wall_clock_rollback |
| Clients per room | 20 (MAX_CLIENTS_PER_ROOM) |
src/server/src/ws/constants.rs, src/server/src/ws/handlers/join.rs |
full_destination_preserves_previous_membership, full_room_returns_explicit_error |
| JWT authentication deadline | 10 s (AUTH_TIMEOUT_SECONDS) |
src/server/src/routes.rs, src/server/src/ws/connection.rs |
unauthenticated_jwt_connection_times_out_and_is_cleaned_up |
| Plugin token issuance | 30 tokens per minute per Jellyfin user (MaxTokensPerMinute) |
src/plugins/jellyfin/OpenWatchParty/Controllers/OpenWatchPartyController.cs, src/plugins/jellyfin/OpenWatchParty/TokenRateLimiter.cs |
SequentialRequestsStopExactlyAtLimit, ParallelBurstNeverExceedsLimit, WindowResetAndUsersAreIndependent |
| Name length | 100 characters (MAX_NAME_LENGTH) |
src/server/src/ws/constants.rs, src/server/src/ws/validation.rs |
test_is_valid_name_invalid, test_sanitize_name_truncation |
| Chat message length | 500 characters (MAX_CHAT_MESSAGE_LENGTH) |
src/server/src/ws/constants.rs, src/server/src/ws/handlers/chat.rs |
validate_chat_too_long, validate_chat_counts_characters_not_bytes |
| Client-side name length | 100 characters (MAX_NAME_LENGTH) |
src/clients/jellyfin-web/ws/validation.js |
src/clients/jellyfin-web/tests/participant-names.test.js |
| Client-side chat length | 500 characters (MAX_CHAT_LENGTH) |
src/clients/jellyfin-web/ws/validation.js, src/clients/jellyfin-web/chat/input.js |
src/clients/jellyfin-web/tests/chat-input-length.test.js |
| Trusted proxy handling | Comma-separated IPs/CIDRs (TRUSTED_PROXIES) |
parse_trusted_proxies and client_ip in src/server/src/routes.rs |
forwarded_for_requires_a_trusted_proxy |
| Slow client queue | Channel buffer 100, 250 ms close enqueue, 1 s writer shutdown (CLIENT_CHANNEL_BUFFER, CLOSE_ENQUEUE_TIMEOUT_MS, WRITER_SHUTDOWN_TIMEOUT_MS) |
src/server/src/ws/constants.rs, src/server/src/ws/connection.rs |
slow_client_does_not_block_shutdown_close_enqueue |
Per-IP Limits: Verdict
Connection admission per IP is implemented and tested. The session server caps concurrent WebSocket connections both globally (MAX_CONNECTIONS, default 256) and per effective client IP (MAX_CONNECTIONS_PER_IP, default 32). The effective IP is the direct peer address unless that peer is listed in TRUSTED_PROXIES, in which case the first address from the right of X-Forwarded-For that is not itself trusted is used. An upgrade over either cap is rejected with HTTP 429, before any authentication or room work. Tests: connection_limits_are_global_per_ip_and_released_on_drop and forwarded_for_requires_a_trusted_proxy in src/server/src/routes.rs. Configuration: TRUSTED_PROXIES and MAX_CONNECTIONS_PER_IP.
Per-IP message rate limiting is out of scope for now. Messages remain limited per connection (30 per 1000 ms window).
- Rationale: A per-IP message bucket requires storing the effective IP on every client plus a shared, synchronized bucket map with eviction and cleanup. That adds contention to the hot message path and more state to reason about, while the connection cap already limits how many independent budgets one IP can hold. The intended production topology terminates traffic at a reverse proxy, which can rate limit by address with thresholds the operator controls.
- Residual risk: An authenticated client can open up to
MAX_CONNECTIONS_PER_IPconnections and multiply the per-connection message allowance (32 x 30 = 960 messages per second with defaults), and the connection cap also applies to unauthenticated sockets. Operators facing abuse should rate limit at the reverse proxy and may lowerMAX_CONNECTIONS_PER_IP. - Safe configuration: Set
TRUSTED_PROXIESonly to addresses or narrow CIDRs of proxies you operate, for exampleTRUSTED_PROXIES=10.20.0.0/16. A too-broad value lets direct clients spoofX-Forwarded-For, bypassing the per-IP connection cap and defeating future per-IP limits. Behind shared NAT, keep in mind that all users of that address share the 32-connection budget.
The verdict is revisited if real deployments report flooding spread across many connections from a single address.
Known Limitations
| Limitation | Status |
|---|---|
| No room passwords | Planned |
| No per-user permissions inside a room (the host seeks, everyone can play, pause and chat) | Planned |
| Ephemeral sessions and rooms | By design |
| Automated asymmetric signing-key rotation | Not available; the operator must coordinate key replacement and trust-store updates |
| Message rate limiting per connection, not per IP | Documented above (see Per-IP Limits: Verdict) |
| No per-token revocation | By design (short TTL; rotate the secret or revoke the signing key) |
| No durable audit log | By design (warn-level logs only) |
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 remain valid until expiration unless their verification key is changed or revoked | Rotate the shared secret to invalidate HS256 tokens; revoke a trusted RSA key to invalidate RS256 tokens |
Token Lifecycle
- Tokens cannot be individually revoked - Once issued, a token is valid until it expires
- Shared-secret rotation invalidates HS256 tokens and invite tickets - It does not invalidate tokens signed with an active asymmetric key
- Revoking an RSA public key invalidates RS256 tokens signed by that key
- 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 a JWT Key Is Compromised
- For HS256 or hybrid, immediately rotate the shared secret on the plugin and session server; restart the server. Existing HS256 tokens and invite tickets become invalid.
- For asymmetric mode, revoke the compromised key from the session-server trust store. There is no automated signing-key rotation command, so coordinate replacement-key generation and pairing before relying on asymmetric authentication again.
- Ask users to reload Jellyfin Web to obtain a fresh session token.
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 runtime uses Alpine Linux. Its base image is pinned by digest, and the Dockerfile upgrades Alpine packages during the image build; the installed package versions can therefore change between builds from the same source commit.
The runtime stage is pinned by digest (alpine:3.24@sha256:…) and Dependabot
re-pins it weekly (docker ecosystem, infra/docker). The builder stage
(rust:*-alpine) is not part of the shipped image and is not scanned.
Security Scanning
Container images are automatically scanned on every push and weekly:
- Trivy: Scans for CVEs in OS packages and dependencies
- Results: Uploaded to GitHub Security tab
- Severity filter: CRITICAL and HIGH vulnerabilities are flagged
ignore-unfixed: only findings with a published fix are blocking
CVE Policy
The build runs apk upgrade --no-cache in the runtime stage before installing
ca-certificates and curl. Every build therefore ships the latest security
fixes published in the Alpine 3.24 repositories, even when the pinned base
digest still contains older packages (for example a vulnerable zlib).
Together with ignore-unfixed: true, this makes the gate self-healing: once
Alpine publishes a fixed package, the next build picks it up and Trivy passes
without waiting for a digest bump. A PR is blocked only when a fixable HIGH or
CRITICAL vulnerability is still present in the built image — that is a real
actionable finding, not database drift.
Consequence: two builds of the same commit can produce slightly different
package versions. The digest pin fixes the base layer; apk upgrade floats
the installed packages to the latest security state of the branch.
Current Security Posture
The release build recorded 0 findings against Trivy 0.70 with CI-equivalent
flags (--severity CRITICAL,HIGH --ignore-unfixed), and 0 findings at any
severity with --ignore-unfixed. This is a result for that scanned build, not
a guarantee about future image rebuilds. Check the current Security workflow
and the digest of the image you deploy.
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
- Use GitHub Private Vulnerability Reporting, as described in
SECURITY.md. - Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
Next Steps
- Deployment - Production deployment
- Monitoring - Monitor for issues
- Troubleshooting - Common problems