OAuth2 in Front of Every Service, for Free

Series: You Don’t Need a VPC | Post 5 of 5


The Problem

Most self-hosted services have their own auth: Home Assistant has its own login, VS Code Server has a password, etc. Managing N separate credential sets is annoying, and some services have weak auth or none at all.

The pattern that scales: put oauth2-proxy in front of everything. One login (Google), one session cookie, one place to manage who’s allowed in.


oauth2-proxy as a Sidecar

For each protected service, run an oauth2-proxy container that handles the auth flow and proxies through to the real service:

homeassistant-proxy:
  image: quay.io/oauth2-proxy/oauth2-proxy:latest
  environment:
    - OAUTH2_PROXY_UPSTREAMS=http://192.168.50.249:8123
    - OAUTH2_PROXY_EMAIL_DOMAINS=*
    - OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE=/etc/oauth2-proxy/allowed-emails.txt
    - VIRTUAL_HOST=homeassistant.mukulhase.com
  volumes:
    - ./allowed-emails.txt:/etc/oauth2-proxy/allowed-emails.txt:ro

The VIRTUAL_HOST label registers it with nginx-proxy. All traffic hits oauth2-proxy first; only authenticated, allowlisted users get through to the real service.


Email Allowlist

Instead of managing users in Auth0 or Google Workspace, a flat file works fine for a household:

[email protected]
[email protected]

Mount it read-only into every proxy container. Adding someone: edit the file, redeploy. Removing someone: same.


Auth0 for Richer Identity

Some services need more than “is this a valid Google user” — they need the user’s identity in a JWT claim for downstream authorization. For the home MCP server, Auth0 sits between Google and the service:

User → Auth0 (Google as IdP) → JWT with email claim → home-mcp

An Auth0 Action adds the email to the access token:

exports.onExecutePostLogin = async (event, api) => {
  api.accessToken.setCustomClaim('email', event.user.email);
};

This Action is managed in Terraform — not manually configured in the Auth0 dashboard:

resource "auth0_action" "add_email_claim" {
  name    = "Add email to access token"
  runtime = "node18"
  deploy  = true
  code    = file("${path.module}/auth0_action.js")
 
  supported_triggers {
    id      = "post-login"
    version = "v3"
  }
}

The home-mcp service validates the JWT and checks the email claim against its own allowlist. Auth0 handles the Google OAuth dance; the service handles authorization.


Two Users, Two Environments

The Claude Code (openclaw) setup has two separate instances — one for each person in the household. Each gets its own:

  • Container with its own workspace volume
  • oauth2-proxy with its own allowed-emails list
  • Subdomain
openclaw:
  environment:
    - VIRTUAL_HOST=openclaw.mukulhase.com
 
openclaw-auro:
  environment:
    - VIRTUAL_HOST=openclaw-auro.mukulhase.com
 
openclaw-proxy:
  environment:
    - OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE=/etc/oauth2-proxy/allowed-emails.txt
 
openclaw-proxy-auro:
  environment:
    - OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE=/etc/oauth2-proxy/allowed-emails-auro.txt

Same infrastructure, different access control. Neither user can reach the other’s environment.


The Result

Every service in the stack is behind auth. No service needs to implement auth itself. The access control layer is a flat file and a Terraform file — both version-controlled, both auditable.

This is what a VPC security group + IAM policy gives you in AWS. Here it’s oauth2-proxy + a text file.


Series Wrap-Up

The full stack:

  • Cloudflare Tunnel replaces public IP exposure + cert management
  • nginx-proxy + docker-gen replaces load balancer + service discovery
  • Rootless Podman + SELinux replaces IAM instance roles + security groups
  • 1Password CLI replaces Secrets Manager
  • oauth2-proxy + Auth0 replaces Cognito + API Gateway authorizers

Total monthly cost: $0 (Cloudflare free tier, 1Password personal, hardware you already own).

You don’t need a VPC.