Skip to content

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.

Never expose the Pinchy Web service directly. Use a reverse proxy with TLS termination.

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;
}

Only port 443 (HTTPS) needs to be exposed. Block everything else.

Terminal window
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 443/tcp
ufw enable
Terminal window
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -j DROP

PostgreSQL stores all Pinchy data on disk — user accounts, agent configurations, audit logs, and encrypted API keys. Encrypt the underlying volume.

Terminal window
# Encrypt the partition (destructive — do this before deploying)
cryptsetup luksFormat /dev/sdX
cryptsetup open /dev/sdX pinchy-data
mkfs.ext4 /dev/mapper/pinchy-data
mount /dev/mapper/pinchy-data /var/lib/docker/volumes

Enable FileVault in System Settings → Privacy & Security. This encrypts the entire startup disk, including Docker volumes.

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:true

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:/data

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: 1G

The .env file contains secrets like DATABASE_URL, BETTER_AUTH_SECRET, and AUDIT_HMAC_SECRET. Protect it.

Terminal window
chmod 600 .env
chown root:root .env

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.txt

Pinchy’s Docker Compose setup uses an internal network. PostgreSQL and OpenClaw are not exposed to the host network.

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:

Terminal window
# Expose on all interfaces — only for initial setup, remove before production
PINCHY_PORT=0.0.0.0:7777
Terminal window
# Check that only Pinchy Web publishes ports
docker 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.

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-internal

The 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.

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.

EndpointWindowMaxThreat addressed
/sign-in/* (incl. /email)60s5Credential stuffing, brute force
/sign-up/* (incl. /email)5 min3Account-creation spam
/forget-password[/*]10 min3Password-reset email spam (DOS against user inboxes)
/reset-password[/*]10 min5Reset-token brute force
/request-password-reset10 min3Reset email spam
/send-verification-email10 min3Verification email spam
/change-password10 min5Post-auth abuse (stolen session)
/change-email10 min3Account takeover via email change (stolen session)
All other auth endpoints10s100Generic 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.

  • 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.failed events, repeated failures are visible and rate-bounded.
  • Reset-flow abuse. Tight windows on /forget-password and /send-verification-email prevent a single IP from spamming a victim’s inbox or burning through reset tokens.
  • Post-auth account takeover. If a session is stolen, /change-email and /change-password are throttled hard enough that an attacker cannot quickly pivot to permanent control.
  • 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.failed audit 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-For to 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.

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.

For each state-changing request:

  1. The Origin header must match the request’s own scheme + host (reconstructed from x-forwarded-host and x-forwarded-proto when behind a reverse proxy).
  2. If Origin is absent, the Referer header is checked against the same allow-list.
  3. If neither header matches, the request is rejected with 403 Forbidden and an auth.csrf_blocked audit log entry is written.

Two route prefixes are exempt:

  • /api/auth/* — Better Auth enforces its own trustedOrigins allow-list here.
  • /api/internal/* — OpenClaw plugins call these routes with Authorization: Bearer <gateway-token>. Bearer-token routes are not CSRF-able (browsers can’t forge Authorization headers cross-origin), and the plugins are non-browser clients that don’t send Origin/Referer.

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:

# nginx
proxy_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.

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.

The CSRF gate is layered with two other request-source defenses:

  • Domain lock (Settings → Security → Domain lock) — rejects any request whose Host header doesn’t match the configured domain. Closes off requests that come in via direct IP, an unconfigured hostname, or a host header injection.
  • SameSite=Lax session 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).

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.

Terminal window
openssl rand -hex 32
.env
AUDIT_HMAC_SECRET=your-generated-hex-string

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.

Create regular backups with pg_dump:

Terminal window
# Backup
docker compose exec db pg_dump -U pinchy pinchy | gzip > "pinchy-backup-$(date +%Y%m%d).sql.gz"
# Restore
gunzip < pinchy-backup-20260222.sql.gz | docker compose exec -T db psql -U pinchy pinchy

Encrypt backups before storing them off-site:

Terminal window
# Backup + encrypt with GPG
docker compose exec db pg_dump -U pinchy pinchy | gzip | gpg --symmetric --cipher-algo AES256 > "pinchy-backup-$(date +%Y%m%d).sql.gz.gpg"
# Decrypt + restore
gpg --decrypt pinchy-backup-20260222.sql.gz.gpg | gunzip | docker compose exec -T db psql -U pinchy pinchy

Set up a cron job for daily backups:

/etc/cron.d/pinchy-backup
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.gpg

Check for new Pinchy releases and update:

Terminal window
docker compose pull
docker compose up -d
docker image prune -f

Always 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).

Use tools like Trivy to scan your running images for known vulnerabilities:

Terminal window
trivy image ghcr.io/heypinchy/pinchy:latest

Consider 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.

Monitor the Pinchy Web service by polling its health endpoint:

Terminal window
curl -f https://pinchy.example.com/api/health || echo "Pinchy is down"

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: 3

Monitor Docker logs for errors and suspicious activity:

Terminal window
# Follow all service logs
docker compose logs -f
# Filter for errors
docker compose logs pinchy 2>&1 | grep -i error

For production, forward logs to a centralized logging system (e.g., Loki, Elasticsearch) and set up alerts for:

  • Authentication failures (auth.failed audit events)
  • CSRF-blocked write attempts (auth.csrf_blocked audit events)
  • Password resets (auth.password_reset_completed — a successful reset revokes the target user’s existing sessions)
  • Denied tool executions (tool.denied audit events)
  • Chat-runtime errors (chat.agent_error — umbrella event for every failure shape, group by detail->>'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

Use the audit trail API to monitor for security-relevant events programmatically:

Terminal window
# Check for failed logins in the last 24 hours
curl -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.