Terminal Security
Session Token Authentication
Every terminal session requires a cryptographically signed token. The backend generates a short-lived JWT-like token before sending the terminal_start command to the agent. The agent verifies the token before allocating a PTY.
Token Format
Compact base64url(payload).base64url(signature) using ed25519 (NATS account keypair).
Token Claims
| Claim | Description |
|---|---|
session_id | UUID tying the token to one specific session |
agent_id | Agent verifies this matches its own registered agent ID (not host_id) |
login | Linux user the PTY should run as |
user_id | Who initiated the session (audit trail) |
exp | Expiration — 1 minute from issuance |
The token uses agent_id (the UUID assigned to the enrolled agent) rather than host_id. This means a replacement agent on the same host with a different agent_id will reject tokens issued for the old agent — ensuring session tokens are tightly bound to the specific agent process.
Verification Flow (Agent-Side)
- Verify ed25519 signature against the NATS account public key
- Check token is not expired (
exp > now) - Check
agent_idmatches the agent's own registered ID - Check
loginmatches the requested Linux user - If any check fails → reject, log warning, send error ack — no PTY allocated
Key Properties
| Property | How It's Enforced |
|---|---|
| No unauthorized shells | Agent requires valid token signature from trusted account key |
| No cross-host replay | Token embeds agent_id, agent rejects mismatches |
| No login substitution | Token embeds login, agent rejects mismatches |
| No replay after expiry | 1-minute TTL, checked before PTY allocation |
| No token forgery | ed25519 private key (account seed) never leaves the backend |
Reconnect Security
When the frontend auto-reconnects after an unexpected disconnect, the backend issues a new session token for the reconnect attempt. The reconnect is only permitted if all of the following hold:
- The reconnecting user is the same user who started the original session (
user_idmatches) - The target host matches the original session's
host_id - The original session has not ended — the PTY must still be alive within the
disconnect_timeoutlinger window
If any check fails, the reconnect is rejected and a new session must be initiated from scratch. This prevents session hijacking by ensuring that even during the linger window, only the authenticated session owner can resume the shell.
First-Message WebSocket Auth
The session token is transmitted as the first WebSocket message after the connection is established — it is never included in the URL as a query parameter.
Why Not a Query Parameter?
Embedding tokens in URLs causes them to appear in:
- Web server access logs (logged by default)
- Browser history and bookmarks
- Referrer headers sent to third-party resources
- Proxies and load-balancer logs
By sending the token as the first binary WebSocket message, the token travels only over the encrypted WebSocket payload and is never exposed in any URL.
Auth Flow
Browser → Backend: WebSocket upgrade (no token in URL)
Backend: Accept connection, wait for first message
Browser → Backend: First message = base64(session_token)
Backend: Validate token, look up session state
Backend → Browser: Auth OK → begin I/O relay
Auth fail → close WebSocket with 4001 code
The WebSocket connection is terminated immediately if the first message is not a valid session token, or if it arrives after the 10-second auth timeout.
Transport Security
Encryption in Transit
All traffic is encrypted via TLS:
| Hop | Encryption |
|---|---|
| Browser → Backend | WSS (WebSocket over TLS) via Cloudflare |
| Backend → NATS | TLS with strong cipher suites |
| NATS → Agent | TLS with strong cipher suites |
NATS Subject Isolation
Terminal I/O uses scoped NATS subjects that prevent cross-tenant data leakage:
proxima.{clientSlug}.{envSlug}.{hostID}.terminal.out.{sessionID}
proxima.{clientSlug}.{envSlug}.{hostID}.terminal.ctrl.{sessionID}
Agent JWT permissions restrict each agent to publish/subscribe only under its own host prefix. The NATS server enforces this at the protocol level.
What the Backend Can See
The backend relays PTY traffic but does not store it. An operator with access to the backend process could theoretically observe terminal I/O in memory. This is equivalent to Teleport's proxy model, where the proxy terminates SSH and re-encrypts to the node.
RBAC
See the RBAC & Permissions page for:
- Role-based terminal access configuration
- Allow/deny rules with label matching
- Multi-role merge semantics
- Validation constraints
Multi-Party Session Access
- Observers require
terminal_sessions:readpermission - Participants require
terminal_sessions:writepermission - Client scoping enforced — peers must have access to the session's client
- Peer join/leave events are recorded in the audit trail
- Session owner always sees who is connected — no silent observation
- Maximum 10 peers per session prevents resource exhaustion
Rolling Upgrades
The session token system supports rolling upgrades:
- Backend deployed first (no agent update): Backend sends signed tokens, but old agent ignores them (no
AccountPubKeyconfigured). Terminal works without verification. - Agent deployed first (no backend update): Agent has
AccountPubKeybut backend sends empty tokens. Agent skips verification whenAccountPubKeyis empty. - Both deployed: Full verification active.
After both are deployed, set AccountPubKey on the agent (extracted automatically from the user JWT issuer) and verification is enforced.
Login Template Sandboxing
Login templates in terminal specs use Go's text/template with restricted execution:
- Restricted function set: Only
lower,upper,replace,trimPrefix,trimSuffix,default, andprintfare available. Nocall, no OS access, no file I/O. - Execution timeout: Templates must complete within 1 second.
- Save-time validation: Templates are parsed and dry-run with dummy data when the role is saved. Invalid templates are rejected before they reach production.
- Output validation: Resolved logins must match the Linux username regex. Invalid results are filtered with a warning log.
File Transfer Security
- User isolation: File operations run as the session's Linux user — OS file permissions are enforced
- SHA-256 integrity: Every transfer is hashed and logged in the audit trail
- Path validation: Path traversal (
..) is rejected - Size limit: 100MB per file, enforced on browser, backend, and agent
- Audit trail: Every upload and download is recorded with filename, path, size, and SHA-256 hash
- Dedicated NATS subject: File data flows on a separate subject from terminal I/O, preventing interference
K8s Node Terminal Security
Terminal access to Kubernetes nodes uses nsenter to enter the host namespace from the DaemonSet agent pod.
DaemonSet security context:
hostPID: true— shares the host's PID namespace (required fornsenter -t 1)SYS_PTRACEcapability — allows ptrace of PID 1 to enter namespacesSYS_ADMINcapability — allows mount namespace operationsreadOnlyRootFilesystem: true— pod filesystem remains read-only- Not
privileged: true— only the minimum capabilities needed
Why not privileged: true? Privileged mode grants unrestricted host access and is a security audit red flag. Targeted capabilities are sufficient for nsenter.
User isolation: Sessions run as the target Linux user via su -, not as root. Host user creation and sudoers rules execute inside the host namespace through nsenter.
Host User Security
When auto-creating host users:
- Password expiry —
chage -E 1expires the password immediately. Users can only connect via Proxima terminal, never via local password login. - Tracking groups —
proxima-keepandproxima-dropsystem groups identify managed users. - Root required — the agent must run as root to create users (
useradd,chage, etc.). - Home directory — created manually with
0700permissions,/etc/skelfiles copied (symlinks skipped). - Cleanup — drop-mode users are deleted by a background loop every 5 minutes, with a 30-second grace period and linger-awareness.
Sudoers File Management
When host_sudoers is configured:
- Files are written to
/etc/sudoers.d/proxima-<username>with mode0440 - Validated with
visudo -cfbefore activation — invalid files are deleted immediately - In drop mode, sudoers files are cleaned up with the user
- Rules are prefixed with the login username
Session Recording Security
- Encryption at rest: Recordings stored in Cloudflare R2 with server-side encryption (AES256)
- Presigned URLs: Download links expire after 1 hour — no permanent direct access to storage
- Per-client retention: Each client configures retention (default 90 days). Expired recordings are automatically deleted from R2
- Access control: Users can view their own session recordings. Admins with
terminal_sessions:readcan view all recordings - Agent-side recording: Recordings are written on the agent during the session, transferred to backend via NATS, then uploaded to R2. Agent never has storage credentials
- Size cap: 10MB gzipped per recording prevents runaway storage from heavy output sessions
UID Range Security
- System UIDs (below 1000) are excluded — UID ranges must start at 1000+
- Minimum range size of 100 prevents trivial hash collisions
- UID allocation is deterministic (FNV-1a hash) — no central state to corrupt
- On collision, falls back to natural allocation with a warning log