Problem
Google OAuth requires registering specific host:port combinations as authorized redirect URIs. In production this is straightforward — one origin per app. Without Moat, running multiple services locally means each gets its own port (localhost:3001, localhost:3002, etc.) and each port must be registered as a separate redirect URI in Google's console. With Moat, multiple applications share a single host:port (localhost:8080) via subdomain routing (e.g. web.run1.localhost:8080, web.run2.localhost:8080), but Google doesn't allow wildcard subdomains in redirect URIs.
The OAuth relay solves this by giving Google a single registered callback, then routing the authorization code to the correct application.
How Moat Routing Already Works
Moat maps subdomained requests to containers:
web.run1.localhost:8080 → container1:3000
web.run2.localhost:8080 → container2:3000
The OAuth relay extends this pattern to the OAuth callback.
Design
What Moat Injects
Moat injects three values into each application container:
| Variable |
Description |
MOAT_OAUTH_RELAY_URL |
URL to start the OAuth flow via Moat |
GOOGLE_CLIENT_ID |
Moat-managed OAuth client ID |
GOOGLE_CLIENT_SECRET |
Moat-managed OAuth client secret |
In production, the application uses its own GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and redirects to Google directly. These variable names are not Moat-specific — the application reads the same variables in both environments.
Flow
Browser App Moat Relay Google
│ │ │ │
│── click login ──▶│ │ │
│◀── redirect ─────│ │ │
│ │ │ │
│── GET relay?app=myapp ─────────────────▶│ │
│◀── redirect to Google ──────────────────│ │
│ │ │
│── authorize ──────────────────────────────────────────────────▶│
│◀── redirect to Moat callback ──────────────────────────────────│
│ │ │
│── code= ───────────────────────────────▶│ │
│◀── redirect to app callback + code ─────│ │
│ │ │
│── /__auth/callback?code=… ──▶│ │ │
│ │── exchange code ───────────────────────────▶│
│ │◀── tokens ──────────────────────────────────│
│◀── session ──────│ │ │
Step by Step
- User clicks "Login." App redirects browser to
${MOAT_OAUTH_RELAY_URL}?app=myapp.
- Moat relay initiates OAuth with Google, using its registered redirect URI (
oauthrelay.localhost:8080/callback).
- User authenticates with Google.
- Google redirects browser to Moat's callback with
?code=....
- Moat looks up which app started the flow, and redirects the browser to
web.myapp.localhost:8080/__auth/callback?code=....
- App receives the authorization code and exchanges it for tokens using
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET — the same code path as production.
- App establishes session. Login complete.
What the App Does
The app's callback handler is identical in all environments. It always:
- Receives an authorization code.
- Exchanges it for tokens.
- Establishes a session.
It does not know or care whether the code arrived via Google directly or via Moat's relay.
Production Comparison
|
Production |
Local (Moat) |
| Browser redirects to |
Google directly |
Moat relay → Google |
| Google calls back to |
App's registered redirect URI |
Moat's registered redirect URI |
| Code delivered to app by |
Google |
Moat (forwarded) |
| App exchanges code for tokens |
Yes |
Yes |
| App callback handler (e.g.) |
/__auth/callback |
/__auth/callback |
| OAuth credentials owned by |
App |
Moat (injected into app) |
The app's callback handler is the same in both environments. The path itself is up to the app. The only difference is how the authorization code arrives.
What Moat Needs to Track
Per login attempt:
- Which app initiated the flow (the
app parameter).
- Where to forward the code — derived from the app's subdomain routing (e.g.
app=myapp → web.myapp.localhost:8080).
This is the same routing table Moat already maintains.
What Moat Registers with Google
A single OAuth client with one authorized redirect URI:
https://oauthrelay.localhost:8080/callback
No per-app registration. No enumerating ports.
Non-Goals
- Abstracting OAuth away from applications. Apps still do their own token exchange.
- Hiding credentials from containers. The client ID and secret are injected — apps use them directly.
- Replacing production OAuth infrastructure. This is a local development convenience.
Problem
Google OAuth requires registering specific
host:portcombinations as authorized redirect URIs. In production this is straightforward — one origin per app. Without Moat, running multiple services locally means each gets its own port (localhost:3001,localhost:3002, etc.) and each port must be registered as a separate redirect URI in Google's console. With Moat, multiple applications share a singlehost:port(localhost:8080) via subdomain routing (e.g.web.run1.localhost:8080,web.run2.localhost:8080), but Google doesn't allow wildcard subdomains in redirect URIs.The OAuth relay solves this by giving Google a single registered callback, then routing the authorization code to the correct application.
How Moat Routing Already Works
Moat maps subdomained requests to containers:
The OAuth relay extends this pattern to the OAuth callback.
Design
What Moat Injects
Moat injects three values into each application container:
MOAT_OAUTH_RELAY_URLGOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETIn production, the application uses its own
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETand redirects to Google directly. These variable names are not Moat-specific — the application reads the same variables in both environments.Flow
Step by Step
${MOAT_OAUTH_RELAY_URL}?app=myapp.oauthrelay.localhost:8080/callback).?code=....web.myapp.localhost:8080/__auth/callback?code=....GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET— the same code path as production.What the App Does
The app's callback handler is identical in all environments. It always:
It does not know or care whether the code arrived via Google directly or via Moat's relay.
Production Comparison
/__auth/callback/__auth/callbackThe app's callback handler is the same in both environments. The path itself is up to the app. The only difference is how the authorization code arrives.
What Moat Needs to Track
Per login attempt:
appparameter).app=myapp→web.myapp.localhost:8080).This is the same routing table Moat already maintains.
What Moat Registers with Google
A single OAuth client with one authorized redirect URI:
No per-app registration. No enumerating ports.
Non-Goals