Security

Credential encryption, authentication, session management, and request validation.

AgentDesk stores sensitive credentials (provider API keys, OAuth tokens, Telegram bot tokens, Notion access tokens) encrypted in the database and authenticates all API access through a layered model.

Credential Encryption (AES-256-GCM)

All sensitive values are encrypted at rest using AES-256-GCM before being written to the SQLite database. The implementation lives in src/lib/crypto.ts.

Key management

The encryption key is AGDESK_SECRET_KEY — a 32-byte (64-character) hex string stored in .env:

# Generated by agdesk setup or auto-generated on first boot
AGDESK_SECRET_KEY=a1b2c3d4...64-hex-characters

Key resolution:

  1. If AGDESK_SECRET_KEY is set and is valid 32-byte hex, use it directly
  2. If set but wrong length, derive a 32-byte key via SHA-256(value) (logged warning)
  3. If unset, derive a deterministic fallback via SHA-256("agdesk-encryption-fallback-v1") (loud warning — not safe for production)

The load-env.ts boot module auto-generates a strong key and persists it to .env (mode 0600) if one is missing, so production installs always have a real key.

Ciphertext format

Encrypted values are stored as three colon-separated base64 strings:

<iv_base64>:<authTag_base64>:<ciphertext_base64>
  • IV: 12-byte random nonce (GCM standard), generated fresh for every encrypt call
  • Auth tag: 16-byte GCM authentication tag for tamper detection
  • Ciphertext: the encrypted payload

Legacy key migration

When AGDESK_SECRET_KEY is added to an existing install, rows encrypted under the old derived fallback key still need to decrypt. The decrypt() function tries:

  1. The primary key (from AGDESK_SECRET_KEY)
  2. Legacy fallback keys in order (the pre-2026-04 Telegram salt, then the generic salt)

A successful fallback decrypt logs a warning prompting the operator to re-save the record, which re-encrypts it under the primary key. Decryption fails with an error only if every key attempt fails.

What gets encrypted

DataDatabase tableColumn
LLM provider API keys / OAuth tokensproviderscredential_encrypted
Telegram bot tokenstelegram_configbot_token
Notion access tokensnotion_connectionsaccess_token

Provider credentials are only decrypted at query time when the session pool builds the per-agent environment, and are never included in API list/read responses.

Authentication

AgentDesk uses two authentication paths: session-based auth for human users (dashboard) and token-based localhost auth for agent CLI tools.

Session-based auth (dashboard users)

Human users authenticate via email + password. The flow:

  1. Setup (POST /api/v1/auth/setup) — Creates the first owner account. Only succeeds once (atomic check-and-create in a transaction).
  2. Login (POST /api/v1/auth/login) — Verifies credentials, creates a session, sets an agdesk-session cookie.
  3. Invite (POST /api/v1/auth/invite) — Owner/admin creates a 7-day invite token. The invitee registers at /register?token=<id>.

Password hashing

Passwords are hashed with bcrypt at cost factor 12 using bcryptjs:

// Hash on registration/setup
const hash = await bcrypt.hash(password, 12);

// Verify on login
const valid = await bcrypt.compare(password, hash);

Minimum password length is 8 characters, enforced at both setup and login endpoints. Emails are normalized (trimmed, lowercased) before storage and lookup.

Session tokens

Sessions are UUID v4 tokens stored in the auth_sessions table:

PropertyValue
Token formatcrypto.randomUUID()
Duration30 days
Cookie nameagdesk-session
Cookie flagshttpOnly, sameSite=lax, path=/
Secure flagOff by default; set AGDESK_COOKIE_SECURE=true for HTTPS

Expired sessions are cleaned up periodically (at most once every 10 minutes) during session validation. When a session lookup finds an expired token, it deletes the row immediately.

Rate limiting

Login attempts are rate-limited per IP address:

ParameterValue
Max failed attempts5
Window15 minutes
ResetOn successful login

The client IP is read from the x-agdesk-real-ip header, which server.ts sets from req.socket.remoteAddress on every request — overwriting any client-supplied value to prevent spoofing.

Localhost auth (agent CLI)

Agent CLI tools (ad-* skill scripts) run on the same machine as AgentDesk and authenticate via the localhost bypass. The security model has two layers:

Layer 1: Local IP verification

The request must originate from a local socket. AgentDesk trusts only:

  • x-agdesk-real-ip header (set by server.ts from the actual socket address, never from client headers)
  • The URL hostname for direct connections

X-Forwarded-For and X-Real-IP are explicitly not trusted — anyone can spoof them. This prevents a leaked token from granting admin access through a reverse proxy.

Layer 2: AGDESK_INTERNAL_TOKEN (defense in depth)

When AGDESK_INTERNAL_TOKEN is configured (auto-generated by agdesk setup), localhost requests must also present a matching x-agdesk-token header. The comparison uses constant-time string equality (XOR-based, not ===) to prevent timing attacks.

The constant-time comparison is implemented in pure JavaScript (no crypto.timingSafeEqual) so it works in both the Node.js runtime and Next.js Edge Runtime (middleware).

Local IP required ──> yes ──> Token configured? ──> yes ──> Token matches? ──> ALLOW
                  └─> no ──> DENY                  └─> no ──> ALLOW (no token = dev mode)
                                                                └─> no ──> DENY

Token-only callers (no local IP) are always rejected. This prevents the “AgentDesk behind reverse proxy + leaked token = full admin from anywhere” privilege escalation.

Middleware

The Next.js middleware (src/middleware.ts) runs on every request and enforces auth at the edge:

  • Public paths: /setup, /login, /register, /api/v1/auth/*, /_next/*, static assets — no auth required
  • API routes from localhost: Allowed via isLocalhost() check
  • API routes from remote: Must have a valid agdesk-session cookie
  • Page routes: Redirect to /login?from=<path> if no session cookie

The middleware only checks cookie existence. Full session validation (DB lookup, expiry check) happens in API route handlers via requireAuth().

HMAC Provider Routing

When agents use an OpenAI-compatible provider, the SDK subprocess talks to AgentDesk’s local translator proxy (POST /v1/messages). The provider ID is encoded into a dummy API key signed with HMAC-SHA256:

agdesk-proxy-<providerId>.<hex-hmac-sha256>

The HMAC key is AGDESK_INTERNAL_TOKEN. The proxy handler verifies the signature using crypto.timingSafeEqual before looking up the provider and forwarding the request.

This prevents:

  • Same-host attackers without AGDESK_INTERNAL_TOKEN from forging requests that consume provider quota
  • LAN/remote attackers from reaching the proxy at all (loopback-only enforcement)

The proxy handler rejects non-loopback callers with a bare 404 (indistinguishable from “no such endpoint” to an external scanner) before even reading the request body.

Request Origin Validation

For requests that write generated agent files (CLAUDE.md, TOOLS.md), AgentDesk resolves the public URL from request headers but only trusts origins that match the allowlist:

  1. Origin header
  2. X-Forwarded-Proto + X-Forwarded-Host
  3. Host header

Each candidate is checked against:

  • AgentDesk’s own configured URL (AGDESK_URL or computed from host+port)
  • server.allowedHosts in config.json
  • AGDESK_ALLOWED_HOSTS environment variable

Hostnames are canonicalized (lowercased, default ports stripped) before comparison. If no candidate matches the allowlist, the system falls back to the configured AGDESK_URL.

Upload Security

Static file serving for uploads (/uploads/*) enforces:

  • Filename sanitization: Non-alphanumeric characters (except ., -, _) are stripped
  • Symlink resolution: realpathSync() verifies the resolved path is within the uploads directory, preventing directory traversal via symlinks
  • Content-Type sniffing prevention: X-Content-Type-Options: nosniff header on all upload responses
  • Immutable caching: Cache-Control: public, max-age=31536000, immutable

Security Checklist

For production deployments:

  • Verify AGDESK_SECRET_KEY is a real 64-character hex string (not the derived fallback). Check server logs for [crypto] AGDESK_SECRET_KEY not set warnings.
  • Set AGDESK_INTERNAL_TOKEN to a strong random value (auto-generated by agdesk setup).
  • Set AGDESK_COOKIE_SECURE=true if serving over HTTPS.
  • Configure server.allowedHosts or AGDESK_ALLOWED_HOSTS to include your custom domain if using a reverse proxy.
  • The bootstrap env vars (AGDESK_BOOTSTRAP_*) are automatically wiped from .env after the first boot seeds the provider — verify they are gone.
  • The .env file should have restrictive permissions. The auto-generated key is written with mode 0600.