Auth & CLI Streaming — A Secure Remote Terminal


Written by Thierry K (human) · AI-assisted
Claude Code Browser Terminal — JWT·TOTP + xterm.js PTY Implementation
Claude Code browser terminal authentication and secure terminal streaming concept image
EP3 — Authentication and CLI Streaming: Securely controlling Claude Code from the browser

Picking Up Where We Left Off

EP1 explained why we dropped RDP, and EP2 laid out the FastAPI + WebSocket + React architecture. The server is running and the WebSocket pipeline is connected. Now it’s time to tackle the real problem — how do we verify who’s connecting, and how do we securely expose a Claude Code browser terminal?

This episode covers everything from JWT + bcrypt authentication to TOTP 2FA, Tailscale auto-login, and xterm.js + PTY-based Claude Code browser terminal streaming.

Why Authentication Matters — Isn’t Tailscale Enough?

If we’re inside a Tailscale VPN, can’t we just skip authentication? Short answer — no.

Tailscale is network-level access control. It governs who can enter the network, not who can do what inside the dashboard. Multiple people may share the VPN, and devices can be lost or stolen. Network access ≠ application authentication.

That’s why Claudie’s Session Dashboard implements three layers of authentication:

  1. bcrypt-hashed passwords — primary login
  2. JWT tokens — session persistence (including WebSocket)
  3. TOTP 2FA — optional second-factor verification

Layer Tailscale CGNAT IP detection on top for auto-login, and you get airtight security without sacrificing usability.

JWT + bcrypt — The Core Auth Flow

Passwords are stored using bcrypt. Per the OWASP 2025 guidelines, a cost factor of 12 or higher remains secure. Argon2id is the better choice for greenfield projects, but bcrypt is still going strong.

# server/auth.py — Password hash generation and verification
import bcrypt

# On signup: password → bcrypt hash
hashed = bcrypt.hashpw(
    password.encode(),
    bcrypt.gensalt()
).decode()

# On login: compare input against stored hash
bcrypt.checkpw(
    password.encode(),
    user["password_hash"].encode()
)

After a successful login, a JWT token is issued. The payload carries sub (username), role (admin/viewer), and exp (expiration time). This token serves as the credential for every subsequent REST API call and WebSocket connection.

Token refresh is handled by re-login rather than a dedicated refresh endpoint. Dashboard sessions default to 24 hours, and in a Tailscale auto-login environment, re-authentication is frictionless even after expiration — so this approach is sufficient.

JWT TOTP Tailscale authentication flow diagram
Auth flow — bcrypt password verification → JWT issuance → TOTP 2FA (optional) → Tailscale CGNAT auto-login

The critical piece is WebSocket authentication. HTTP endpoints accept a Bearer token in the header, but WebSocket requires the token as the first message:

# server/terminal_routes.py — WebSocket authentication
@router.websocket("/ws/terminal")
async def terminal_ws(websocket: WebSocket):
    await websocket.accept()
    # First message: JWT token authentication
    auth_msg = await websocket.receive_json()
    token = auth_msg.get("token")
    user = verify_jwt(token)
    if not user:
        await websocket.close(code=4001)
        return
    # Auth successful → start terminal protocol
    await websocket.send_json(
        {"type": "auth_ok"}
    )

TOTP 2FA — pyotp 30-Second Window Implementation

If passwords alone don’t feel secure enough, TOTP 2FA can be enabled. It’s compatible with Google Authenticator, Authy, and similar apps.

# server/auth.py — TOTP setup and verification
import pyotp

# Enable 2FA: generate secret key
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)

# Generate URI for QR code
uri = totp.provisioning_uri(
    name=username,
    issuer_name="Claudie Dashboard"
)

# On login: verify 6-digit code
# valid_window=1 → allows ±30 seconds for clock drift
totp.verify(code, valid_window=1)

pyotp implements RFC 6238, so the setup is just a few lines of code. valid_window=1 tolerates ±30 seconds of device clock drift.

Tailscale Auto-Login — The CGNAT Range Detection Trick

Security is airtight, but typing a password plus a TOTP code every time from home gets tedious. So when connecting through the Tailscale VPN, auto-login kicks in.

The key is the IP range Tailscale assigns. It uses the CGNAT (Carrier-Grade NAT) range 100.64.0.0/10, defined in RFC 6598. This range never appears on the public internet, so a connection from this range definitively means the client is inside the Tailscale VPN.

# server/auth.py — Tailscale CGNAT detection
import ipaddress

_TAILSCALE_NETWORK = ipaddress.ip_network(
    "100.64.0.0/10"
)

def is_tailscale_ip(self, ip_str: str) -> bool:
    """Determine whether the connection originates from within Tailscale VPN"""
    return (
        ipaddress.ip_address(ip_str)
        in _TAILSCALE_NETWORK
    )

Implemented entirely with Python’s standard library ipaddress module. Zero external dependencies. When this function returns True, a JWT is issued automatically without requiring a password.

Here’s the actual flow — the browser sends a GET request to /api/auth/tailscale-login, the server extracts the client IP and runs is_tailscale_ip(). If the IP falls within the Tailscale range, an admin-privileged JWT is returned immediately; otherwise, a 401 is sent back. The frontend tries this endpoint first on page load. If it succeeds, the user lands directly on the dashboard with no login screen.

Setup Wizard — Zero-Config First Run

With an authentication system in place, how is the first admin account created? When the dashboard launches for the first time, a Setup Wizard appears automatically.

The logic is straightforward — if active_admin_count() == 0, the system enters Setup Wizard mode. Once the admin account is created, the default admin account is automatically deactivated, and subsequent visits show the normal login screen.

No config files, no environment variables — just a check for whether an active admin exists in the SQLite database. A zero-config philosophy.

After the Setup Wizard, user management is available in the Settings page. Admins can add new users, change roles (admin/viewer), and reset passwords. The users table includes is_active and is_default columns, so the default admin account is automatically set to is_default=True, is_active=False after setup completes. No forgotten accounts left as backdoors.

Claudie and Siwol comic strip about authentication and CLI streaming
Three layers of security, but the Korean input bug was a 3-line fix — Claudie & Siwol talk auth streaming

Claude Code Browser Terminal — xterm.js + PTY Connection

Now for the highlight of this episode. A Claude Code browser terminal that lets you operate Claude Code directly from the browser.

Let’s start with the architecture:

LayerComponentRole
Frontendxterm.jsBrowser terminal emulator
Serverterminal_proxyWebSocket relay + authentication
Agentterminal_handlerPTY process management
Processclaude CLIActual Claude Code execution

The data flow looks like this: xterm.js → WebSocket → terminal_proxy → WebSocket → terminal_handler → PTY → claude. It’s bidirectional. Claude’s output travels the same path in reverse to render in the browser.

PTY WebSocket relay chain diagram
Claude Code browser terminal architecture — xterm.js ↔ WebSocket ↔ PTY ↔ claude bidirectional streaming

The WebSocket protocol uses three message types:

  • start — initiate a new terminal session (or include a --resume session ID)
  • input — relay keyboard input (base64-encoded — critical for CJK support)
  • resize — terminal resize (cols, rows → PTY resize syscall)

One more thing — there’s a common pitfall when using xterm.js in a React SPA. Navigating to another page unmounts the component, which kills the terminal session. Claudie Dashboard solves this with a CSS display toggle. CliPage stays mounted at all times; when it’s not visible, it’s hidden with display: none. The PTY process keeps running through React route changes, so switching back to the CLI page from the dashboard shows the running Claude Code session exactly where you left it.

pywinpty vs Unix pty — Platform-Specific Implementation

PTY (Pseudo-Terminal) APIs differ across operating systems. Claudie’s Session Dashboard supports both Windows and macOS/Linux, so platform branching is mandatory.

On Windows, pywinpty is used:

# agent/terminal_handler.py — Windows PTY
from winpty import PtyProcess

proc = PtyProcess.spawn(
    cmd,
    cwd=self._cwd,
    env=self._spawn_env
)
# Read output: proc.read()
# Write input: proc.write(data)

On Unix/macOS, the standard library pty module is used:

# agent/terminal_handler.py — Unix PTY
import pty as pty_module
import asyncio, os

master_fd, slave_fd = pty_module.openpty()

proc = await asyncio.create_subprocess_exec(
    *cmd,
    stdin=slave_fd,
    stdout=slave_fd,
    stderr=slave_fd,
    cwd=self._cwd,
    env=self._spawn_env,
    preexec_fn=os.setsid
)

Windows PTY uses a synchronous API (PtyProcess), while Unix uses an asynchronous API (asyncio.create_subprocess_exec). This difference is abstracted inside terminal_handler.py, so upper layers don’t need to care about the platform.

Korean Input Issue — InvalidCharacterError and UTF-8 base64

The Claude Code browser terminal works perfectly — until you type Korean. InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.

The culprit is JavaScript’s btoa() function. It only handles the Latin-1 (ISO 8859-1) range (0x00–0xFF). Korean, Japanese, and other CJK characters fall outside this range, causing btoa() to throw an exception.

The fix is 3 lines:

// frontend/src/components/CliPage.tsx
// CJK-safe base64 encoding
const bytes = new TextEncoder().encode(data)
let binary = ""
for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i])
}
sendInput(btoa(binary))

TextEncoder().encode() converts the string to a UTF-8 byte array, then each byte is mapped to a Latin-1 character before passing it to btoa(). Since byte values always fall within 0x00–0xFF, the error never occurs.

Hundreds of lines went into building three layers of security, yet the Korean input bug was a 3-line fix. That’s the irony of software development.

claude –resume — Session Continuity with 30-Second Cache

Even if a session disconnects in the Claude Code browser terminal, you can pick up where you left off. The claude --resume <session-id> command restores the session.

Retrieving the session list requires scanning Claude Code’s JSONL session files. With potentially dozens to hundreds of files, performance matters:

# agent/terminal_handler.py — Session scanning (summary)
import asyncio
from functools import partial

async def list_sessions(self):
    # Check 30-second TTL cache
    if self._session_cache_valid():
        return self._session_cache

    loop = asyncio.get_event_loop()
    # Run file I/O in thread pool (prevent blocking)
    sessions = await loop.run_in_executor(
        self._executor,
        partial(self._scan_jsonl_files)
    )
    self._update_cache(sessions)
    return sessions  # Top 20, most recent first

Each JSONL file isn’t read in full. Only the first 8 KB (session ID, start time) and last 2 KB (final message) are read. Even if a JSONL file is tens of megabytes, lookup time is negligible. Combined with a 30-second TTL cache, consecutive session list requests from the UI trigger file system I/O at most once every 30 seconds. Pre-warming on agent startup ensures even the first request is fast.

From the user’s perspective, the dashboard displays previous sessions and a single click fires claude --resume <session-id>. Context from a session days ago is restored in full.

Next Episode Preview

Authentication and the Claude Code browser terminal are complete. Security is locked down in three layers, and PTY streaming enables real-time control. With this system in production, opening the dashboard URL on a phone from the couch takes under 5 seconds — Tailscale VPN connection verified, CGNAT range detected, auto-login, browser terminal open. Claude Code is running with full MCP access on the Windows workstation, and all I’m holding is a phone.

But there’s one piece missing — “How much does it actually cost to run this dashboard?”

The Claude Code MAX subscription is a flat $200/month. But xAI image generation, Gemini API, and Qwen are billed separately. Without seeing all costs on a single screen in real time, the true cost of AI operations remains invisible.

EP4 implements MCP telemetry and cost tracking. We’ll instrument 7 MCP servers, collect Claude token usage to calculate “what would it cost on pure API without MAX?”, and aggregate actual spending by provider — Gemini, xAI, Qwen — on a daily basis. Real Spend / If All API / MAX Savings — three numbers displayed on the dashboard in real time.

SERIES — Remote-Control Claude Code Without RDP

EP1 The Problem — Why We Dropped RDP
EP2 Architecture — FastAPI + WebSocket + React
EP3 Auth and CLI Streaming — Secure Remote Terminal ◀ You are here
EP4 Cost Tracking and MCP Telemetry — Knowing What You Spend (coming soon)

Discover more from AI-Girls Lab

Subscribe to get our latest posts delivered to your inbox.