2024.07.23

authentication works until localhost enters the conversation

oauth looked like one redirect until i had a local frontend, a production API and several URLs trying to decide where the user belongs.

authentication has a funny way of working perfectly right until the frontend moves to localhost.

the production application can sign in. the API is healthy. Google redirects correctly. then i run the frontend locally, click the same button and discover that authentication is not one request. it is a trip through several systems, and every one of them has an opinion about URLs.

the flow is bigger than the button

a normal API request is simple enough:

text
local frontend -> production API -> response

OAuth is a navigation flow:

text
local frontend -> API sign-in endpoint -> Google -> API callback -> frontend destination

the browser leaves the frontend entirely. by the time the callback completes, the API needs to know where to send the user. if it always redirects to the production website, local sign-in technically succeeds and still feels broken.

three URL problems wearing one coat

the first problem is CORS. can the local page call the API at all?

the second is the OAuth callback. has the provider approved the exact callback URL owned by the API?

the third is the return destination. after the API exchanges the authorization code and creates the session, which frontend origin should receive the user?

these values are related, but they are not interchangeable.

adding localhost as a Google callback does not fix a production API callback. widening CORS does not decide the final redirect. changing the final redirect does not make an unapproved origin safe.

carrying the origin safely

one useful approach is to let the frontend send an intended return origin when sign-in begins. the API preserves it through the OAuth flow, often inside signed state, then validates it before redirecting.

the validation is the important part.

ts
const allowedOrigins = new Set([ class="syntax-string">"https:class="syntax-commentclass="syntax-string">">//example.com", class="syntax-string">"http:class="syntax-commentclass="syntax-string">">//localhost:class="syntax-number">3002", ]); function safeReturnOrigin(value: string | undefined) { return value && allowedOrigins.has(value) ? value : class="syntax-string">"https:class="syntax-commentclass="syntax-string">">//example.com"; }

without an allowlist, a convenient returnTo parameter becomes an open redirect. the application would happily authenticate a user and send them to an attacker-controlled page afterwards. convenience has a sense of humour.

cookies join the argument

even after the redirects are correct, cookies may fail because of Secure, SameSite, domain or credential settings. a frontend and API on different origins force the browser to apply rules that same-origin development quietly hides.

this is why debugging authentication by staring at the login button is painful. the useful evidence is in the whole chain: request origin, redirect location, callback URL, response cookie and final destination.

the lesson for me is that OAuth is not "sign in with Google." it is a controlled transfer of trust between origins. once localhost enters the conversation, every origin has to be named precisely. close enough is not a URL.