Hardening Guide
This guide covers security hardening measures for production Pinchy deployments. Pinchy is self-hosted — infrastructure security is your responsibility. These recommendations assume a Linux server with Docker, but most apply to any OS.
Reverse proxy with TLS
Section titled “Reverse proxy with TLS”Never expose the Pinchy Web service directly. Use a reverse proxy with TLS termination.
Caddy (recommended — automatic HTTPS)
Section titled “Caddy (recommended — automatic HTTPS)”pinchy.example.com { reverse_proxy localhost:7777}Caddy automatically provisions and renews Let’s Encrypt certificates.
server { listen 443 ssl http2; server_name pinchy.example.com;
ssl_certificate /etc/letsencrypt/live/pinchy.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/pinchy.example.com/privkey.pem;
# TLS hardening ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on;
# Security headers add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always;
location / { proxy_pass http://127.0.0.1:7777; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (required for agent communication) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }}
server { listen 80; server_name pinchy.example.com; return 301 https://$host$request_uri;}Firewall rules
Section titled “Firewall rules”Only port 443 (HTTPS) needs to be exposed. Block everything else.
UFW (Ubuntu/Debian)
Section titled “UFW (Ubuntu/Debian)”ufw default deny incomingufw default allow outgoingufw allow sshufw allow 443/tcpufw enableiptables
Section titled “iptables”iptables -A INPUT -p tcp --dport 22 -j ACCEPTiptables -A INPUT -p tcp --dport 443 -j ACCEPTiptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPTiptables -A INPUT -i lo -j ACCEPTiptables -A INPUT -j DROPDisk encryption
Section titled “Disk encryption”PostgreSQL stores all Pinchy data on disk — user accounts, agent configurations, audit logs, and encrypted API keys. Encrypt the underlying volume.
Linux (LUKS)
Section titled “Linux (LUKS)”# Encrypt the partition (destructive — do this before deploying)cryptsetup luksFormat /dev/sdXcryptsetup open /dev/sdX pinchy-datamkfs.ext4 /dev/mapper/pinchy-datamount /dev/mapper/pinchy-data /var/lib/docker/volumesmacOS (FileVault)
Section titled “macOS (FileVault)”Enable FileVault in System Settings → Privacy & Security. This encrypts the entire startup disk, including Docker volumes.
Docker security
Section titled “Docker security”Run as non-root
Section titled “Run as non-root”The Pinchy Docker image runs as a non-root user by default. Verify this is not overridden in your docker-compose.yml:
services: pinchy: # Do NOT add: user: root security_opt: - no-new-privileges:trueRead-only filesystem
Section titled “Read-only filesystem”Mount the container filesystem as read-only and whitelist only the directories that need writes:
services: pinchy: read_only: true tmpfs: - /tmp volumes: - pinchy-data:/dataResource limits
Section titled “Resource limits”Prevent a single container from consuming all host resources:
services: pinchy: deploy: resources: limits: cpus: "2.0" memory: 2G reservations: memory: 512M db: deploy: resources: limits: cpus: "1.0" memory: 1GEnvironment variable protection
Section titled “Environment variable protection”The .env file contains secrets like DATABASE_URL, BETTER_AUTH_SECRET, and AUDIT_HMAC_SECRET. Protect it.
File permissions
Section titled “File permissions”chmod 600 .envchown root:root .envDocker Secrets (Swarm mode)
Section titled “Docker Secrets (Swarm mode)”If you use Docker Swarm, use secrets instead of environment variables:
services: pinchy: secrets: - db_password - auth_secret - audit_hmac_secret
secrets: db_password: file: ./secrets/db_password.txt auth_secret: file: ./secrets/auth_secret.txt audit_hmac_secret: file: ./secrets/audit_hmac_secret.txtNetwork isolation
Section titled “Network isolation”Pinchy’s Docker Compose setup uses an internal network. PostgreSQL and OpenClaw are not exposed to the host network.
Localhost binding (default since v0.4.3)
Section titled “Localhost binding (default since v0.4.3)”Pinchy’s default port binding is 127.0.0.1:7777, which means it only accepts connections from localhost. A reverse proxy (Caddy, nginx) running on the same host forwards traffic from ports 80/443 to Pinchy on 7777.
If you need to override the binding temporarily (e.g. for initial setup without a reverse proxy), set PINCHY_PORT in your .env:
# Expose on all interfaces — only for initial setup, remove before productionPINCHY_PORT=0.0.0.0:7777Verify network isolation
Section titled “Verify network isolation”# Check that only Pinchy Web publishes portsdocker compose ps --format "table {{.Name}}\t{{.Ports}}"The pinchy service should show 127.0.0.1:7777->7777/tcp. The db and openclaw services should show no published ports.
Custom Docker network
Section titled “Custom Docker network”For additional isolation, define an explicit internal network:
networks: pinchy-internal: internal: true pinchy-external:
services: pinchy: networks: - pinchy-internal - pinchy-external ports: - "127.0.0.1:7777:7777" db: networks: - pinchy-internal openclaw: networks: - pinchy-internalThe internal: true flag prevents containers on that network from reaching the internet. The pinchy service bridges both networks — it can reach the database and OpenClaw internally, while also serving HTTP traffic.
See the VPS Deployment guide for the complete production setup walkthrough.
Authentication rate limiting
Section titled “Authentication rate limiting”Pinchy ships with hardened, per-path rate limits on every credential-handling endpoint, configured at packages/web/src/lib/auth.ts. The values are explicit (not delegated to library defaults) so a future Better Auth upgrade cannot silently weaken them.
Rate limiting is enabled in production only (NODE_ENV === "production"). It is intentionally off in dev and in Playwright E2E runs that exercise login flows.
Per-path thresholds (per IP)
Section titled “Per-path thresholds (per IP)”| Endpoint | Window | Max | Threat addressed |
|---|---|---|---|
/sign-in/* (incl. /email) | 60s | 5 | Credential stuffing, brute force |
/sign-up/* (incl. /email) | 5 min | 3 | Account-creation spam |
/forget-password[/*] | 10 min | 3 | Password-reset email spam (DOS against user inboxes) |
/reset-password[/*] | 10 min | 5 | Reset-token brute force |
/request-password-reset | 10 min | 3 | Reset email spam |
/send-verification-email | 10 min | 3 | Verification email spam |
/change-password | 10 min | 5 | Post-auth abuse (stolen session) |
/change-email | 10 min | 3 | Account takeover via email change (stolen session) |
| All other auth endpoints | 10s | 100 | Generic flood protection |
The /sign-in/* and /sign-up/* wildcard entries cover any future sub-path (OAuth callbacks, magic-link, passkey) so a new sign-in mechanism added in a later release inherits the hardened threshold instead of falling back to Better Auth’s weaker /sign-in/* default.
What this protects against
Section titled “What this protects against”- Credential stuffing. 5 sign-in attempts per minute per IP (vs. Better Auth’s default 18/min) makes leaked-password lists impractical to replay against a single IP.
- Targeted brute force. Combined with the audit trail’s
auth.failedevents, repeated failures are visible and rate-bounded. - Reset-flow abuse. Tight windows on
/forget-passwordand/send-verification-emailprevent a single IP from spamming a victim’s inbox or burning through reset tokens. - Post-auth account takeover. If a session is stolen,
/change-emailand/change-passwordare throttled hard enough that an attacker cannot quickly pivot to permanent control.
Limits of IP-based rate limiting
Section titled “Limits of IP-based rate limiting”- IP rotation defeats per-IP limits. A residential-proxy pool can sidestep these thresholds. We don’t ship per-account lockout yet — a planned brute-force lockout will trigger on N consecutive
auth.failedaudit events for the same email. - Storage is in-memory. Counters reset on container restart and are not shared across replicas. For single-replica self-hosted deployments this is acceptable; for multi-replica deployments switch to a shared store (Redis) — not currently exposed as a config option, file an issue if you need this.
- Trust the X-Forwarded-For header from your reverse proxy only. Better Auth derives the client IP from headers. If your reverse proxy does not set
X-Forwarded-Forto the real client IP — or worse, blindly trusts the header from the client — rate limiting can be bypassed. The reverse-proxy snippets earlier in this guide are configured correctly.
Customizing the thresholds
Section titled “Customizing the thresholds”The values are intentional defaults. If your CISO requires stricter limits (e.g. 3 sign-ins per minute), edit getAuthRateLimitConfig in packages/web/src/lib/auth.ts and rebuild your image. The auth-config-consistency.test.ts and auth.test.ts test suites pin the expected values so accidental loosening will fail CI.
Cross-Site Request Forgery (CSRF) protection
Section titled “Cross-Site Request Forgery (CSRF) protection”Pinchy enforces an Origin/Referer check on every state-changing API request (POST/PUT/PATCH/DELETE under /api/). This prevents an attacker from triggering admin actions through a logged-in admin’s browser by hosting a malicious page that auto-submits a cross-site form to your Pinchy instance.
How it works
Section titled “How it works”For each state-changing request:
- The
Originheader must match the request’s own scheme + host (reconstructed fromx-forwarded-hostandx-forwarded-protowhen behind a reverse proxy). - If
Originis absent, theRefererheader is checked against the same allow-list. - If neither header matches, the request is rejected with
403 Forbiddenand anauth.csrf_blockedaudit log entry is written.
Two route prefixes are exempt:
/api/auth/*— Better Auth enforces its owntrustedOriginsallow-list here./api/internal/*— OpenClaw plugins call these routes withAuthorization: Bearer <gateway-token>. Bearer-token routes are not CSRF-able (browsers can’t forgeAuthorizationheaders cross-origin), and the plugins are non-browser clients that don’t sendOrigin/Referer.
Reverse proxy configuration
Section titled “Reverse proxy configuration”The Origin check relies on knowing the public-facing host and scheme. If your reverse proxy strips or rewrites these headers, the check will reject legitimate requests. Set both headers explicitly:
# nginxproxy_set_header X-Forwarded-Host $host;proxy_set_header X-Forwarded-Proto $scheme;# Caddy sets X-Forwarded-Host and X-Forwarded-Proto by default — no extra config needed.pinchy.example.com { reverse_proxy localhost:7777}If your deployment chains multiple reverse proxies, X-Forwarded-Host may arrive as a comma-separated list (public.example.com, internal:7777). The gate compares Origin against the first (outermost) hop — the public name the browser actually saw. Each proxy in the chain should append, not overwrite, the existing header.
Auditing CSRF probes
Section titled “Auditing CSRF probes”Blocked requests appear in the audit log as auth.csrf_blocked with outcome: failure. The detail payload captures method, pathname, origin, referer, and the offending remoteAddress, so probes show up in the audit log alongside failed logins. Use the audit dashboard (or GET /api/audit?eventType=auth.csrf_blocked) to review them.
Defense in depth
Section titled “Defense in depth”The CSRF gate is layered with two other request-source defenses:
- Domain lock (
Settings → Security → Domain lock) — rejects any request whoseHostheader doesn’t match the configured domain. Closes off requests that come in via direct IP, an unconfigured hostname, or a host header injection. SameSite=Laxsession cookie — Better Auth’s default. Prevents cookies from being attached to most third-party requests, but is not sufficient on its own (top-level form posts under Lax can still attach cookies in some browsers).
Together they enforce: the request hit the right destination (domain lock), came from a legitimate source (CSRF gate), and the session cookie is unforgeable (Better Auth + SameSite).
AUDIT_HMAC_SECRET configuration
Section titled “AUDIT_HMAC_SECRET configuration”The audit trail uses HMAC-SHA256 to sign every log entry. By default, Pinchy auto-generates a secret at startup. For production, set a persistent secret.
Generate a secret
Section titled “Generate a secret”openssl rand -hex 32Set it in your environment
Section titled “Set it in your environment”AUDIT_HMAC_SECRET=your-generated-hex-stringGateway-auth bootstrap
Section titled “Gateway-auth bootstrap”OpenClaw 2026.5.12+ refuses to bind on a non-loopback interface without a gateway.auth.token in openclaw.json. The gateway logs Refusing to bind gateway to lan without auth and exits, which surfaces in docker compose logs pinchy-openclaw as a restart loop until the wrapper’s grace period kicks in.
Pinchy handles this automatically. Pinchy’s boot-init step seeds gateway.auth.{mode: "token", token} into openclaw.json before the OpenClaw container starts, even on a fresh install before the setup wizard runs. The seeded token lives in the openclaw-config volume; OpenClaw and Pinchy share the same path.
This is a Pattern C bootstrap credential (see Secret Handling): the token is plaintext in openclaw.json by design. It is the trust root for the OpenClaw container and cannot itself be fetched through Pinchy’s API. Rotate it by regenerating the config (any change in Settings → Providers that triggers a save) and restarting the OpenClaw container.
Failure mode to watch for. If boot-inits.ts cannot reach the database at startup (DB outage, mis-configured DATABASE_URL), the seed step logs a FATAL line and Pinchy refuses to start — better to fail fast than to leave OpenClaw stuck. If you see pinchy-openclaw restart-looping with Refusing to bind gateway to lan without auth and Pinchy itself is healthy, the seed wrote successfully but OpenClaw is reading from a different config path — verify the openclaw-config volume mount.
Backup & recovery
Section titled “Backup & recovery”PostgreSQL backups
Section titled “PostgreSQL backups”Create regular backups with pg_dump:
# Backupdocker compose exec db pg_dump -U pinchy pinchy | gzip > "pinchy-backup-$(date +%Y%m%d).sql.gz"
# Restoregunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchyEncrypted backups
Section titled “Encrypted backups”Encrypt backups before storing them off-site:
# Backup + encrypt with GPGdocker compose exec db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --cipher-algo AES256 > "pinchy-backup-$(date +%Y%m%d).sql.gz.gpg"
# Decrypt + restoregpg --decrypt pinchy-backup-20260222.sql.gz.gpg | gunzip | docker compose exec -T db psql -U pinchy pinchyBackup schedule
Section titled “Backup schedule”Set up a cron job for daily backups:
0 3 * * * root cd /opt/pinchy && docker compose exec -T db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --batch --passphrase-file /root/.pinchy-backup-passphrase --cipher-algo AES256 > /backups/pinchy-$(date +\%Y\%m\%d).sql.gz.gpgUpdate strategy
Section titled “Update strategy”Docker image updates
Section titled “Docker image updates”Check for new Pinchy releases and update:
docker compose pulldocker compose up -ddocker image prune -fAlways read the release notes before updating. Back up the database first. The final docker image prune -f removes the previous image layer that pull left dangling — skip it and pinchy/pinchy-openclaw <none>:<none> layers slowly fill the root volume across upgrades. For a deeper sweep that also drops older pinned versions you no longer need for rollback, run docker image prune -a -f between upgrades (not as part of one).
Dependency scanning
Section titled “Dependency scanning”Use tools like Trivy to scan your running images for known vulnerabilities:
trivy image ghcr.io/heypinchy/pinchy:latestConsider integrating this into a CI pipeline or running it on a schedule. See the SBOM reference for information about Pinchy’s software bill of materials.
Monitoring & alerting
Section titled “Monitoring & alerting”Health endpoint
Section titled “Health endpoint”Monitor the Pinchy Web service by polling its health endpoint:
curl -f https://pinchy.example.com/api/health || echo "Pinchy is down"Docker health checks
Section titled “Docker health checks”Add health checks to your docker-compose.yml:
services: pinchy: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:7777/api/health"] interval: 30s timeout: 10s retries: 3 start_period: 30s db: healthcheck: test: ["CMD-SHELL", "pg_isready -U pinchy"] interval: 30s timeout: 5s retries: 3Log monitoring
Section titled “Log monitoring”Monitor Docker logs for errors and suspicious activity:
# Follow all service logsdocker compose logs -f
# Filter for errorsdocker compose logs pinchy 2>&1 | grep -i errorFor production, forward logs to a centralized logging system (e.g., Loki, Elasticsearch) and set up alerts for:
- Authentication failures (
auth.failedaudit events) - CSRF-blocked write attempts (
auth.csrf_blockedaudit events) - Password resets (
auth.password_reset_completed— a successful reset revokes the target user’s existing sessions) - Denied tool executions (
tool.deniedaudit events) - Chat-runtime errors (
chat.agent_error— umbrella event for every failure shape, group bydetail->>'errorClass'to see what’s degrading) - Invite blocks (
user.invite_blocked— seat-cap or license guard refused an invite) - Container restarts (especially
pinchy-openclaw— see Gateway-auth bootstrap below for a common cause) - High error rates
Audit log monitoring
Section titled “Audit log monitoring”Use the audit trail API to monitor for security-relevant events programmatically:
# Check for failed logins in the last 24 hourscurl -b session_cookie "https://pinchy.example.com/api/audit?eventType=auth.failed&from=$(date -d '24 hours ago' -Iseconds)"See Audit Trail for the full API reference.