Authentication
Prax and TeamWork need a configured access layer before being exposed to users. This guide describes network access through Tailscale and identity-provider integration through an authentication proxy.
35c345e5
View source ↗
Prax and TeamWork need a configured access layer before being exposed to users. This guide describes network access through Tailscale and identity-provider integration through an authentication proxy.
Deployment limit: the current execution sandbox shares one filesystem. Authentication does not isolate mutually untrusted tenants. Use one deployment per untrusted tenant, with separate execution, data, and credentials. A trusted team sharing an environment is a different arrangement; its members must accept that shared boundary. See the single-tenant limitation and deployment topology.
Quick comparison
| Approach | Access model | Configuration | Best for |
|---|---|---|---|
| Tailscale | Private network and tailnet policies | Network setup | One owner or a trusted team |
| OAuth2 Proxy + Google | Google identity in front of the application | Proxy and OAuth configuration | Existing Google Workspace identity |
| OAuth2 Proxy + GitHub | GitHub identity in front of the application | Proxy and OAuth configuration | Existing GitHub organization identity |
| Authentik (self-hosted) | Self-hosted identity provider | OIDC and proxy configuration | Operators maintaining their own identity service |
These are access patterns, not capacity or tenant-isolation claims. Verify the chosen proxy, application, and network configuration in your deployment.
Tailscale
Tailscale provides an encrypted private route governed by your tailnet access policy. It does not remove Prax’s existing host-published ports. Restrict those bindings or firewall them so traffic cannot bypass the intended access layer.
This is a useful starting point for one owner or a trusted team.
How it works
Public Internet ──X──> Your workstation (ports closed)
Your tailnet:
Your laptop (100.64.0.1) ──> Workstation (100.64.0.2:3000) ──> TeamWork
Colleague's laptop (100.64.0.3) ──> Same workstation ──> TeamWork
Tailscale assigns each device a stable IP on your tailnet (100.x.y.z). Traffic is encrypted end-to-end with WireGuard. No central server sees your data.
Step 1: Create a Tailscale account
Create an account at Tailscale. Check the current plan limits for your use case.
Step 2: Bring Prax onto the tailnet
You have two options here. Option 2a (recommended) runs tailscaled
as a Docker sidecar in the Prax compose stack — no host tailscaled installation
is needed, but the default host-published application ports still need restriction,
and the Tailscale node identity is pinned to a Docker volume so
container restarts don’t burn through device slots. Option 2b runs
tailscaled on the host directly, which is fine if you already manage
Tailscale system-wide.
Option 2a: Dockerized Tailscale sidecar (recommended)
- In the Tailscale admin console (Settings → Keys), generate a key with a non-ephemeral device identity for this persistent service. A reusable key supports re-enrollment; keep it private and scope it narrowly. Enable pre-approval only if your tailnet uses device approval. Persisted node state keeps the same identity across restarts. Ephemeral nodes are intended for short-lived workloads; their accounting depends on plan and lifetime, with nodes present for four hours treated as standard tagged devices. See Tailscale’s ephemeral-node documentation.
- Add the key to your Prax
.env:
Without theTS_AUTHKEY=tskey-auth-... TS_HOSTNAME=prax COMPOSE_PROFILES=tailscale TEAMWORK_BASE_URL=https://prax.<your-tailnet>.ts.nettailscaleprofile the sidecar does not start. Current Compose files still require a nonemptyTS_AUTHKEYduring interpolation; for local use withCOMPOSE_PROFILESunset, useTS_AUTHKEY=unused. Replace that value with a real key before enabling this profile. docker compose up -d. The sidecar joins the tailnet automatically and serveshttps://prax.<tailnet>.ts.net/(TeamWork) andhttps://prax.<tailnet>.ts.net:3001/(Grafana, when the observability profile is also active).
Skip ahead to Step 3 below — you don’t need sudo tailscale up
on the host.
Option 2b: Install on the host
# Install
curl -fsSL https://tailscale.com/install.sh | sh
# Start and authenticate
sudo tailscale up
# This prints a URL — open it in any browser to authenticate.
# Example: https://login.tailscale.com/a/abc123def456
# After authenticating, the node joins your tailnet.
# Verify
tailscale ip -4
# 100.64.0.2 (your tailnet IP)
tailscale status
# 100.64.0.2 workstation you@github linux -
macOS:
# Install via App Store or Homebrew
brew install --cask tailscale
# Or download from https://tailscale.com/download/mac
# Start from menu bar icon, or:
sudo tailscale up
Step 3: Install on your client device (laptop, phone)
Install the Tailscale app on whatever device you’ll access TeamWork from:
- macOS/Windows/Linux: https://tailscale.com/download
- iOS/Android: App Store / Play Store
Sign in with the same account. Both devices are now on your tailnet.
Step 4: Access TeamWork via tailnet IP
# Find your workstation's tailnet IP
tailscale ip -4
# 100.64.0.2
# Access TeamWork (replace with your IP)
open http://100.64.0.2:3000
That’s it. No port forwarding, no firewall rules, no certificates. If you’re not on the tailnet, you can’t reach port 3000.
Optional: Use a Tailscale hostname
Instead of remembering IPs, use MagicDNS:
# In Tailscale admin console (https://login.tailscale.com/admin/dns):
# Enable MagicDNS
# Now access via hostname:
open http://workstation.tail12345.ts.net:3000
Optional: HTTPS with Tailscale
Tailscale can provision TLS certificates for your tailnet hostnames:
# On your workstation
sudo tailscale cert workstation.tail12345.ts.net
# Creates workstation.tail12345.ts.net.crt and .key
# Configure your reverse proxy (nginx/Traefik/Caddy) to use these certs
Adding team members
# In Tailscale admin (https://login.tailscale.com/admin/users):
# 1. Click "Invite users"
# 2. They install Tailscale and join your tailnet
# 3. They can now reach your workstation's TeamWork
Access control (ACLs)
As your team grows, restrict who can access what:
// In Tailscale admin → Access Controls:
{
"acls": [
// Everyone can access TeamWork
{"action": "accept", "src": ["group:team"], "dst": ["tag:prax:3000"]},
// Only admins can access Prax API directly
{"action": "accept", "src": ["group:admins"], "dst": ["tag:prax:5001"]},
// Only admins can SSH
{"action": "accept", "src": ["group:admins"], "dst": ["tag:prax:22"]},
]
}
Tailscale + Kubernetes
For K8s deployments, use the Tailscale Kubernetes operator:
helm repo add tailscale https://pkgs.tailscale.com/helmcharts
helm install tailscale tailscale/tailscale-operator -n tailscale --create-namespace \
--set oauth.clientId=... --set oauth.clientSecret=...
Then annotate your TeamWork service:
apiVersion: v1
kind: Service
metadata:
name: prax-teamwork
annotations:
tailscale.com/expose: "true"
tailscale.com/hostname: "prax"
spec:
# ...
TeamWork is now accessible at https://prax.tail12345.ts.net — only from your tailnet.
OAuth2 Proxy + Google OAuth
OAuth2 Proxy is a reverse proxy that requires authentication before any request reaches your app. It supports 20+ providers. This section covers Google — the most common for teams already using Google Workspace.
How it works
User ──> OAuth2 Proxy (:4180) ──> TeamWork (:8000)
│
└── Redirects to Google login
└── Verifies token
└── Sets cookie
└── Forwards X-Forwarded-User, X-Forwarded-Email headers
TeamWork receives every request pre-authenticated. The user’s email is in the X-Forwarded-Email header — but note that TeamWork does not read X-Forwarded-* headers today (teamwork/src/teamwork/proxy_auth.py verifies a signed JWT assertion header, not these). OAuth2 Proxy is therefore a perimeter control: it only protects TeamWork if TeamWork is reachable exclusively through the proxy (Step 5 below). For an in-app check that also rejects requests which bypass the proxy, see Verified in-app check: PROXY_AUTH_*.
Step 1: Create a Google OAuth app
-
Go to Google Cloud Console
-
Create a project (or select an existing one):
- Click the project dropdown at the top → “New Project”
- Name:
prax-auth(or whatever you like) - Click “Create”
-
Enable the OAuth consent screen:
- Navigate to APIs & Services → OAuth consent screen
- Choose External (or Internal if you have Google Workspace and want to restrict to your org)
- Fill in:
- App name:
Prax - User support email: your email
- Authorized domains: your domain (e.g.,
example.com) or leave empty for testing - Developer contact: your email
- App name:
- Click “Save and Continue”
- Scopes: click “Add or Remove Scopes” → select
emailandprofile→ “Update” → “Save and Continue” - Test users: add your email (required for External apps in testing mode)
- Click “Save and Continue” → “Back to Dashboard”
-
Create OAuth credentials:
- Navigate to APIs & Services → Credentials
- Click "+ Create Credentials" → “OAuth client ID”
- Application type: Web application
- Name:
Prax OAuth - Authorized redirect URIs: add your callback URL:
- For local:
http://localhost:4180/oauth2/callback - For production:
https://prax.yourdomain.com/oauth2/callback
- For local:
- Click “Create”
- Copy the Client ID and Client Secret — you’ll need these next
Step 2: Generate a cookie secret
# Random 32-byte secret for session cookies
python3 -c "import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
# Example output: aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789_-AB
Step 3: Add OAuth2 Proxy to docker-compose
Create a docker-compose.override.yml (this layers on top of your existing docker-compose.yml without modifying it):
# docker-compose.override.yml
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
ports:
- "4180:4180"
environment:
# Google OAuth
OAUTH2_PROXY_PROVIDER: google
OAUTH2_PROXY_CLIENT_ID: "YOUR_CLIENT_ID.apps.googleusercontent.com"
OAUTH2_PROXY_CLIENT_SECRET: "YOUR_CLIENT_SECRET"
OAUTH2_PROXY_COOKIE_SECRET: "YOUR_COOKIE_SECRET_FROM_STEP_2"
# Where to send authenticated traffic (TeamWork's API is served by the
# all-in-one `prax` container on port 8000)
OAUTH2_PROXY_UPSTREAMS: "http://prax:8000"
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
# Who can log in (restrict to your domain, or * for any Google account)
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# To restrict to your org: OAUTH2_PROXY_EMAIL_DOMAINS: "yourcompany.com"
# Pass user identity to TeamWork
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
# Cookie settings
OAUTH2_PROXY_COOKIE_SECURE: "false" # Set to true with HTTPS
OAUTH2_PROXY_COOKIE_HTTPONLY: "true"
OAUTH2_PROXY_COOKIE_SAMESITE: "lax"
# Session settings
OAUTH2_PROXY_SESSION_STORE_TYPE: "cookie"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true" # Go straight to Google login
# Redirect after login
OAUTH2_PROXY_REDIRECT_URL: "http://localhost:4180/oauth2/callback"
depends_on:
prax:
condition: service_healthy
Step 4: Start with auth
# Normal start — docker-compose.override.yml is auto-loaded
docker compose up -d
# Access via OAuth2 Proxy port (not TeamWork directly)
open http://localhost:4180
# You'll be redirected to Google login → then back to TeamWork
Step 5: Restrict direct access to TeamWork
Remove the TeamWork port mapping from docker-compose so users can only reach it through the proxy:
# docker-compose.override.yml — add this to hide TeamWork's direct port
services:
teamwork:
ports: !override [] # Remove the 3000:8000 and 8000:8000 mappings
Or if you prefer, just don’t expose port 3000 in your firewall.
Kubernetes setup
For K8s, add OAuth2 Proxy as a sidecar or separate deployment:
# In your values override:
# k8s/my-values.yaml
auth:
enabled: true
provider: google
clientId: "YOUR_CLIENT_ID"
clientSecret: "YOUR_CLIENT_SECRET"
cookieSecret: "YOUR_COOKIE_SECRET"
emailDomains: "yourcompany.com"
Or deploy OAuth2 Proxy via its own Helm chart:
helm repo add oauth2-proxy https://oauth2-proxy.github.io/manifests
helm install oauth2-proxy oauth2-proxy/oauth2-proxy \
--set config.clientID="YOUR_CLIENT_ID" \
--set config.clientSecret="YOUR_CLIENT_SECRET" \
--set config.cookieSecret="YOUR_COOKIE_SECRET" \
--set extraArgs.provider=google \
--set extraArgs.upstream="http://prax-teamwork:8000" \
--set extraArgs.email-domain="*" \
--set ingress.enabled=true \
--set ingress.hosts[0]=prax.yourdomain.com \
-n prax
Verifying user identity in TeamWork
Once OAuth2 Proxy is in front of TeamWork, every request includes:
X-Forwarded-User: alice
X-Forwarded-Email: alice@yourcompany.com
X-Forwarded-Preferred-Username: alice
X-Forwarded-Groups: engineering,admins
TeamWork does not consume these headers as of 2026-09 — nothing in teamwork/src/teamwork/ reads X-Forwarded-User / X-Forwarded-Email, and the app has no per-user workspace routing (see Multi-user workspace routing, which is a design sketch, not shipped code). They are available to a future routing layer; today they are informational only, and identity is enforced at the proxy.
OAuth2 Proxy + GitHub OAuth
Same architecture as Google, but using GitHub as the identity provider. Better for dev teams and open source projects.
Step 1: Create a GitHub OAuth App
- Go to GitHub Developer Settings
- Click “OAuth Apps” → “New OAuth App”
- Fill in:
- Application name:
Prax - Homepage URL:
http://localhost:4180(or your domain) - Authorization callback URL:
http://localhost:4180/oauth2/callback
- Application name:
- Click “Register application”
- Copy the Client ID
- Click “Generate a new client secret” → Copy the Client Secret
Step 2: Configure OAuth2 Proxy
# docker-compose.override.yml
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
ports:
- "4180:4180"
environment:
OAUTH2_PROXY_PROVIDER: github
OAUTH2_PROXY_CLIENT_ID: "YOUR_GITHUB_CLIENT_ID"
OAUTH2_PROXY_CLIENT_SECRET: "YOUR_GITHUB_CLIENT_SECRET"
OAUTH2_PROXY_COOKIE_SECRET: "YOUR_COOKIE_SECRET"
OAUTH2_PROXY_UPSTREAMS: "http://prax:8000"
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
OAUTH2_PROXY_COOKIE_SECURE: "false"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
OAUTH2_PROXY_REDIRECT_URL: "http://localhost:4180/oauth2/callback"
# Optional: restrict to a GitHub org
# OAUTH2_PROXY_GITHUB_ORG: "your-org"
# Optional: restrict to a specific team
# OAUTH2_PROXY_GITHUB_TEAM: "engineering"
depends_on:
prax:
condition: service_healthy
Everything else is the same as the Google setup.
Authentik (self-hosted OIDC)
For full control over authentication — custom branding, LDAP/Active Directory integration, SCIM user provisioning, fine-grained policies, and audit logging. Runs as 3 containers alongside Prax.
When to use Authentik
- You need to support multiple identity sources (Google + GitHub + LDAP)
- You want branded login pages
- You need audit trails for compliance
- You need self-hosted identity policies for users of a trusted environment; untrusted tenants still require separate Prax execution and data boundaries
- You don’t want to depend on external OAuth providers
Architecture
User ──> Authentik Proxy ──> TeamWork
│
└── Authentik Server (OIDC provider)
└── PostgreSQL (auth database)
└── Redis (sessions)
Setup overview
- Deploy Authentik (docker-compose or Helm chart)
- Create an OAuth2/OIDC application in Authentik’s admin UI
- Point OAuth2 Proxy at Authentik’s OIDC endpoints (or use Authentik’s built-in proxy provider)
- Configure allowed users/groups
Authentik’s own docs are excellent: https://docs.goauthentik.io
# docker-compose.override.yml — Authentik services
services:
authentik-server:
image: ghcr.io/goauthentik/server:latest
command: server
environment:
AUTHENTIK_SECRET_KEY: "generate-a-long-random-string"
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-password
AUTHENTIK_POSTGRESQL__NAME: authentik
ports:
- "9000:9000" # Authentik UI + OIDC endpoints
depends_on:
- authentik-db
- authentik-redis
authentik-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
environment:
AUTHENTIK_SECRET_KEY: "same-secret-as-server"
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-password
AUTHENTIK_POSTGRESQL__NAME: authentik
authentik-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: authentik-password
POSTGRES_DB: authentik
volumes:
- authentik-db-data:/var/lib/postgresql/data
authentik-redis:
image: redis:7-alpine
volumes:
- authentik-redis-data:/data
volumes:
authentik-db-data:
authentik-redis-data:
After starting, access http://localhost:9000/if/flow/initial-setup/ to create the admin account, then configure an OAuth2 provider for Prax.
Verified in-app check: PROXY_AUTH_* (IAP / Cloudflare Access)
The one authenticating-proxy integration TeamWork actually implements is the
PROXY_AUTH_* middleware (teamwork/src/teamwork/proxy_auth.py, settings in
teamwork/src/teamwork/config.py). It is default off (the middleware is not
even added). When PROXY_AUTH_ENABLED=true, every request except the exempt paths
(PROXY_AUTH_EXEMPT_PATHS, default /health,/healthz) must carry a valid signed
JWT assertion from the fronting proxy, verified against the provider’s JWKS —
so a request that reaches the bound port without going through the proxy is
rejected by the app itself (401), not only by the firewall. It is fail-closed:
misconfiguration refuses to start.
PROXY_AUTH_ENABLED=true
PROXY_AUTH_PROVIDER=iap # preset: header x-goog-iap-jwt-assertion, ES256, Google JWKS
PROXY_AUTH_AUDIENCE=<IAP backend-service audience> # REQUIRED when enabled
# or:
# PROXY_AUTH_PROVIDER=cloudflare_access # preset: header cf-access-jwt-assertion, RS256
# PROXY_AUTH_AUDIENCE=<Access application AUD tag>
# PROXY_AUTH_ISSUER=https://<team>.cloudflareaccess.com # JWKS URL derived from this
PROXY_AUTH_HEADER, PROXY_AUTH_JWKS_URL, PROXY_AUTH_ALGORITHMS override the
preset for other providers. OAuth2 Proxy and Authentik (above) do not emit a JWT
assertion header in these presets, so with them the perimeter (Step 5) is the
control. Full deployment scenarios: network-exposure
(Scenario B).
Multi-user workspace routing
Status (2026-09): design sketch, not implemented. TeamWork reads no identity header and Prax has no per-email workspace mapping; the flow below is what such a layer would look like.
Once authentication is in place and you’re ready for multi-user, the app needs to map authenticated users to workspaces. The flow:
OAuth2 Proxy ──> X-Forwarded-Email: alice@company.com ──> TeamWork
│
├── Workspace: /workspaces/alice/
├── Qdrant collection: prax-alice
├── Neo4j namespace: prax_alice
└── Sandbox: prax-sandbox-2
With the Kubernetes operator, this mapping is automatic — creating a PraxWorkspace CR provisions everything. Without the operator, TeamWork would need a small middleware to read the header and route accordingly.
Security checklist
| Item | Single user | Multi-user |
|---|---|---|
| Don’t expose TeamWork port publicly | Use Tailscale or firewall | Use OAuth2 Proxy |
| API keys in environment, not code | .env file, chmod 600 |
K8s Secrets or cloud secrets manager |
| HTTPS | Tailscale auto-certs or Let’s Encrypt | cert-manager + ingress |
| Session cookies | N/A | COOKIE_SECURE=true, COOKIE_HTTPONLY=true |
| Restrict login | Tailscale ACLs | EMAIL_DOMAINS or GITHUB_ORG |
| Audit logging | Prax health telemetry | Authentik audit log |
| API key rotation | Manual | Automated via secrets manager |
References
- Tailscale — WireGuard-based mesh VPN
- Tailscale Kubernetes operator
- OAuth2 Proxy — Authentication proxy
- OAuth2 Proxy Helm chart
- Google OAuth2 setup
- GitHub OAuth Apps
- Authentik — Self-hosted identity provider
- Prax K8s deployment — Helm chart and operator docs