Cuando Docker Auth Rompe el Inicio de Sesión Móvil: Una Historia de Bug Multiplataforma
The ticket arrived on a Thursday afternoon: “Google Sign-In not working on mobile.” The support thread was short – three users, all iOS, all reporting the same OAuth spinner that led nowhere. The web app worked. Desktop worked. Only mobile failed, and only for OAuth sign-in. Email and password authentication continued without issue.
This is the kind of bug report that promises a quick fix and delivers an afternoon of confusion. The surface symptoms – a broken auth flow on one platform – point in a dozen directions simultaneously. Redirect URIs. CORS. Token exchange. Session storage. Client-side configuration. Each is plausible. None turned out to be the problem.
The stack
The application runs on a self-hosted Supabase instance – the full Docker Compose stack, deployed on a dedicated server. Supabase’s architecture separates authentication into its own microservice: GoTrue, which handles OAuth flows, token issuance, and user management. GoTrue communicates with PostgreSQL via a direct database connection, reading and writing to the auth schema. The frontend is a React Native application communicating with Supabase via the standard JavaScript client library.
This is a relatively common setup for teams that prefer to control their data. Self-hosted Supabase gives you the full PostgreSQL database, the authentication layer, real-time subscriptions, and the storage service, all running in containers you manage. The tradeoff – and this incident is a precise example of it – is that managed services quietly handle configuration details that become visible failures when you manage them yourself.
The first hypothesis: redirect URI mismatch
OAuth errors on mobile almost always start with redirect URIs. Native apps use custom URL schemes (myapp://auth/callback) rather than HTTPS URLs, and a mismatch between the registered callback and what the OAuth provider expects produces exactly the kind of silent failure we were seeing: the OAuth flow initiates, the browser opens, the user authenticates, and the callback never completes.
We verified the redirect URI configuration in the Google Cloud Console. Checked the Supabase project settings. Verified the React Native app’s deep-link configuration. Everything matched. The URIs were correctly registered on both sides. The OAuth consent screen completed without error – Google was accepting the sign-in. The failure was happening after the callback, somewhere in the token exchange.
The second hypothesis: CORS
Cross-Origin Resource Sharing headers are the second reflex for mobile OAuth debugging. When a mobile app makes requests to a backend running on a custom domain, CORS configuration determines whether the browser’s security model permits the request. A misconfigured CORS policy produces a class of errors that can look identical to an authentication failure.
But in reality, CORS operates at the network layer before authentication logic runs. And the symptom – that email authentication worked while OAuth did not – was inconsistent with a CORS failure. Both use the same GoTrue endpoint. If CORS was rejecting requests, it would reject all of them, not selectively. We added verbose logging to the mobile client. The requests were reaching the GoTrue service. The service was responding. The status codes were 500.
Reading the actual logs
Container logs are where self-hosted debugging diverges most sharply from managed services. With Supabase Cloud, the dashboard surfaces structured logs from every service. With self-hosted Docker, you pull logs from individual containers directly:
docker logs supabase-auth --since 1h 2>&1 | grep -i error
The GoTrue container had been logging errors continuously. The relevant line:
level=error msg="Error creating oauth state" error="ERROR: function gen_random_uuid() does not exist (SQLSTATE 42883)"
This was not an OAuth redirect error. It was a PostgreSQL function resolution failure. gen_random_uuid() is provided by the pgcrypto extension – a standard PostgreSQL extension installed by Supabase in the extensions schema rather than the default public schema. GoTrue was calling gen_random_uuid() without a schema qualifier, expecting PostgreSQL to find it via the search path. It was not finding it.
What search_path does
PostgreSQL resolves unqualified function and table names by searching a sequence of schemas in order. The default is "$user", public: first look in a schema named after the current database user, then in public. If a function lives in any other schema and is not explicitly qualified with its schema name – extensions.gen_random_uuid() instead of gen_random_uuid() – PostgreSQL returns a “does not exist” error regardless of whether the function actually exists.
Supabase’s managed deployment configures the PostgreSQL search_path to include extensions, auth, and public. This configuration is documented in the Supabase codebase but not prominently surfaced in the self-hosting setup guide. On managed Supabase, it is applied automatically. On self-hosted Docker, it depends on what environment variables and initialisation scripts have been applied. Ours had neither.
Why mobile and not web
The question the log explanation does not immediately answer: why did web authentication work while mobile failed? The answer is in how OAuth flows differ between environments.
Web applications using Supabase OAuth default to the implicit flow: the authorisation server returns tokens directly in the URL fragment after the OAuth callback. The client extracts them from the URL, stores them, and the session is established without a separate token exchange step – a code path that happened not to call gen_random_uuid().
Mobile applications following current security best practices use PKCE: the Proof Key for Code Exchange. In PKCE, the client generates a random code verifier, hashes it to produce a code challenge, and sends the challenge to the authorisation server. After the callback, the client sends the original code verifier to the token endpoint, which verifies it before issuing tokens. This token exchange step is the code path that was failing. GoTrue was calling gen_random_uuid() when generating the OAuth state for the PKCE flow, and the extensions schema was not in the search path.
The implicit flow on web bypassed this specific code path. PKCE on mobile did not. A subtle difference in OAuth implementation exposed a configuration error that had existed in the Docker stack since deployment – it simply had not been exercised until a mobile client attempted a PKCE flow.
The fix
The correct fix is to ensure the PostgreSQL search_path includes all schemas that GoTrue and PostgREST depend on. In a Docker Compose setup, this is cleanest when applied at the database level so it persists regardless of which container connects:
-- Run once against the database
ALTER DATABASE postgres SET search_path TO extensions, auth, public, storage;
ALTER ROLE authenticator SET search_path TO extensions, auth, public, storage;
ALTER ROLE supabase_auth_admin SET search_path TO extensions, auth, public, storage;
An alternative is to pass the setting via the PostgreSQL service configuration in Docker Compose:
services:
db:
image: supabase/postgres:15.1.0.117
command:
- postgres
- -c
- search_path=extensions,auth,public,storage
We applied both. After restarting the GoTrue container, mobile OAuth completed without error. Apple Sign-In – which we had been reluctant to test during the debugging process – also worked immediately, confirming that the problem was flow-specific rather than provider-specific.
What this incident reveals about self-hosted debugging
The debugging path – from “auth broken on mobile” to “wrong search_path in PostgreSQL” – took longer than it should have. Several lessons are worth extracting.
Container logs are the ground truth. Both incorrect hypotheses were pursued before reading the GoTrue logs. The logs contained the exact error on the first relevant entry. In a self-hosted Docker stack, reading the logs is the first step, not a last resort after exhausting other theories.
Platform-specific failures in a shared backend almost always indicate a difference in the client code path, not a difference in how the backend treats that specific client. The backend does not know it is serving a mobile app versus a browser. It follows the code path determined by the request parameters. Understanding which code path PKCE activates – as opposed to the implicit flow – would have pointed to the token exchange immediately.
Managed services and self-hosted services are not equivalent configurations. Supabase Cloud applies configuration details that are documented but not automatically applied in the Docker Compose setup. When something works on Supabase Cloud but fails self-hosted, the gap is almost always in one of these configurations. The official self-hosted repository’s issue tracker is often the fastest way to locate the specific setting that is missing.
A bug that “only affects one platform” is often a bug that was always present, exposed only by the first client to exercise a specific code path. In this case, the search_path error existed from the day the Docker stack was deployed. It took months and a mobile OAuth implementation to make it visible.
Related Insights
Artículos relacionados
Más de Cien Contenedores Docker: Nuestra Rutina Mensual de Verificación de Salud
Despliegue de Aplicaciones React en Producción: Configuración Completa de Docker con Traefik como Proxy Inverso
Construyendo un Stack de Desarrollo Multi-Tenant con Docker: Configuración Completa para Despliegues Escalables de Clientes