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:
- If
AGDESK_SECRET_KEYis set and is valid 32-byte hex, use it directly - If set but wrong length, derive a 32-byte key via
SHA-256(value)(logged warning) - 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:
- The primary key (from
AGDESK_SECRET_KEY) - 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
| Data | Database table | Column |
|---|---|---|
| LLM provider API keys / OAuth tokens | providers | credential_encrypted |
| Telegram bot tokens | telegram_config | bot_token |
| Notion access tokens | notion_connections | access_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:
- Setup (
POST /api/v1/auth/setup) — Creates the first owner account. Only succeeds once (atomic check-and-create in a transaction). - Login (
POST /api/v1/auth/login) — Verifies credentials, creates a session, sets anagdesk-sessioncookie. - 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:
| Property | Value |
|---|---|
| Token format | crypto.randomUUID() |
| Duration | 30 days |
| Cookie name | agdesk-session |
| Cookie flags | httpOnly, sameSite=lax, path=/ |
| Secure flag | Off 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:
| Parameter | Value |
|---|---|
| Max failed attempts | 5 |
| Window | 15 minutes |
| Reset | On 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-ipheader (set byserver.tsfrom 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-sessioncookie - 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_TOKENfrom 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:
OriginheaderX-Forwarded-Proto+X-Forwarded-HostHostheader
Each candidate is checked against:
- AgentDesk’s own configured URL (
AGDESK_URLor computed from host+port) server.allowedHostsin config.jsonAGDESK_ALLOWED_HOSTSenvironment 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: nosniffheader on all upload responses - Immutable caching:
Cache-Control: public, max-age=31536000, immutable
Security Checklist
For production deployments:
- Verify
AGDESK_SECRET_KEYis a real 64-character hex string (not the derived fallback). Check server logs for[crypto] AGDESK_SECRET_KEY not setwarnings. - Set
AGDESK_INTERNAL_TOKENto a strong random value (auto-generated byagdesk setup). - Set
AGDESK_COOKIE_SECURE=trueif serving over HTTPS. - Configure
server.allowedHostsorAGDESK_ALLOWED_HOSTSto include your custom domain if using a reverse proxy. - The bootstrap env vars (
AGDESK_BOOTSTRAP_*) are automatically wiped from.envafter the first boot seeds the provider — verify they are gone. - The
.envfile should have restrictive permissions. The auto-generated key is written with mode0600.