GoTrue

Proxy

Keep GoTrue on a private network and expose typed, documented /auth/* routes through your own service.

GoTrue (the auth server behind Supabase Auth) is normally exposed directly to browsers. ezz can front it instead: GoTrue stays on an internal address, your service is the only public door, and the auth endpoints become typed routes that appear in your OpenAPI document alongside everything else.

You get one object that does both jobs. It validates the tokens GoTrue issues, and it proxies the endpoints clients need.

Set it up

gt := auth.NewGoTrue(auth.GoTrueConfig{
    InternalURL: "http://gotrue:9999",              // never publicly routable
    JWTSecret:   os.Getenv("GOTRUE_JWT_SECRET"),    // the same secret GoTrue signs with
    AuditLogger: auth.NewSlogAuditLogger(nil),
})

app := ezz.New().Auth(gt.AuthProvider())
gt.Mount(app, "/auth")

That is the whole integration. Clients now call POST /auth/token instead of GoTrue's POST /token, and your protected routes accept the resulting bearer token.

Configuration

FieldRequiredPurpose
InternalURLyesAbsolute http or https URL of the GoTrue server
JWTSecretyesShared with GoTrue, used to validate incoming tokens
AuditLoggernoRecords proxied calls; see Audit logging
RateLimiternoThrottles sensitive endpoints; nil disables it
ServerHeadersnoHeaders added to every outbound request, such as apikey
HTTPClientnoCustom client; defaults to a 30 second timeout
TrustedProxiesnoCIDRs or IPs of proxies in front of you; empty means edge mode

NewGoTrue panics on an invalid InternalURL or a malformed TrustedProxies entry, so a wiring mistake fails at startup rather than on the first login.

What the two halves do

gt.AuthProvider() returns a *auth.JWTAuthProvider backed by GoTrue's claim layout. It is the value you hand to app.Auth(...), and it takes the same options as any other JWT provider:

app := ezz.New().Auth(
    gt.AuthProvider().WithResolver(auth.Cached(myResolver{db: db}, 5*time.Minute)),
)

gt.Mount(app, prefix) registers the proxied routes under the prefix. Every route is Public() at the ezz layer, because GoTrue does its own bearer-token checking on the endpoints that need it. The full list is on Endpoints.

What happens on a proxied request

For a typed JSON route such as POST /auth/token:

  1. Bind and validate. The request struct is bound and validated like any other ezz route, so a body missing email fails with 400 before GoTrue is contacted.
  2. Rate limit. On sensitive routes, the configured limiter is consulted, keyed by client IP and route. Over the limit means 429.
  3. Build the outbound request. The validated request is re-marshalled as JSON, the query string is carried over, ServerHeaders are applied, the client's Authorization header is forwarded verbatim, and X-Forwarded-For, X-Real-IP, X-Forwarded-Proto, and X-Forwarded-Host are set from the resolved client IP.
  4. Forward and pass through. On success, GoTrue's status, Content-Type, and body are returned byte for byte.
  5. Normalize errors. A 4xx or 5xx from GoTrue is rewritten into ezz's error shape.
  6. Audit. Success or failure is recorded, when an audit logger is configured.

The response types exist for documentation, not for filtering. Because the body is passed through unchanged, fields ezz does not model still reach the client, so a GoTrue upgrade that adds a field does not require a change here.

Error responses

GoTrue error bodies are folded into the standard shape, keeping the original as details:

{
  "error": true,
  "message": "Invalid login credentials",
  "status": 400,
  "details": {
    "error_code": "invalid_credentials",
    "msg": "Invalid login credentials"
  }
}

The message is taken from the first of msg, error_description, error, or message present in GoTrue's body, falling back to the standard status text.

If GoTrue cannot be reached at all, the response is:

{
  "error": true,
  "message": "auth service unavailable",
  "status": 502
}

Reach GoTrue's protected endpoints

Endpoints like GET /auth/user need the caller's token. Because the Authorization header is forwarded verbatim, the client just sends it as usual:

Terminal
TOKEN=$(curl -s localhost:8080/auth/token?grant_type=password \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}' | jq -r .access_token)

curl -s localhost:8080/auth/user -H "Authorization: Bearer $TOKEN"

Add a service key

If your GoTrue deployment expects an apikey header or another shared header, set it once:

gt := auth.NewGoTrue(auth.GoTrueConfig{
    InternalURL: "http://gotrue:9999",
    JWTSecret:   secret,
    ServerHeaders: map[string]string{
        "apikey": os.Getenv("SUPABASE_ANON_KEY"),
    },
})

These headers are added to typed forwards and to the raw redirect routes, and they never appear in responses.

Tune the HTTP client

The default client has a 30 second timeout and the standard transport. For a busy service, give it a connection pool sized for your traffic:

gt := auth.NewGoTrue(auth.GoTrueConfig{
    InternalURL: "http://gotrue:9999",
    JWTSecret:   secret,
    HTTPClient: &http.Client{
        Timeout: 10 * time.Second,
        Transport: &http.Transport{
            MaxIdleConns:        100,
            MaxIdleConnsPerHost: 100,
            IdleConnTimeout:     90 * time.Second,
        },
    },
})

Mount under a different prefix

The prefix is yours to choose, and a trailing slash is trimmed:

gt.Mount(app, "/api/auth")

Whatever you choose has to match GoTrue's API_EXTERNAL_URL, because GoTrue builds email links and OAuth metadata from it. See Deployment.

Next steps

Endpoints

Every proxied route, its types, and which ones are rate limited.

Client IP and rate limits

Trusted proxies, real client IPs, and the Redis limiter.

Deployment

GoTrue settings that have to match, and the fixture harness.

Roles and permissions

Why GoTrue's role claim is not an application role.
Copyright © 2026