The OAuth piece finally clicked
I got the Google Search Console OAuth flow working in production on the Pi, and this was one of those wins that felt small on the surface but removed a whole pile of future friction.
The goal was simple: let a user connect their Google Search Console account, approve access, come back to the app, and have the app store the tokens needed to pull Search Console data later. Not a mock flow. Not a local-only demo. The real flow, running on the Raspberry Pi, behind the production domain, with HTTPS, redirects, cookies, callback handling, token exchange, and refresh token storage all behaving properly.
It took a few passes. OAuth is one of those things where the happy path sounds clean, but every tiny mismatch gets punished. Wrong callback URL. Wrong protocol. Missing proxy headers. Consent screen still in testing. Cookie not sticking after redirect. Refresh token not coming back. Each bug had a clear cause once I found it, but getting there was a bit of a scavenger hunt.
Here’s how I built it, what broke, and what I’d do the same way again.
The production setup
The app is running on a Raspberry Pi in production. The Pi sits behind a normal domain with HTTPS in front of it. The app itself handles the OAuth start route and callback route, while the Google credentials live in environment variables instead of being hardcoded anywhere.
That last part matters because OAuth credentials are not something I want scattered through the codebase. The app only needs to know the client ID, client secret, and redirect URI at runtime. Everything else can be built around those values.
The first version of the flow had three moving parts:
- A route that sends the user to Google’s OAuth consent screen.
- A callback route that receives Google’s response after the user approves access.
- A token exchange step that trades the temporary authorization code for access and refresh tokens.
Once those worked, I could add safer storage, state checks, better logs, and cleaner user-facing messages. I wanted the thin version working first. Then I tightened it up.
Creating the Google app settings
The Google Cloud side needs to match the production app exactly. Close is not good enough. If the app redirects to one URL and Google expects another, the flow stops dead.
I created the OAuth client in Google Cloud, picked a web application client type, and added the production callback URL as an authorized redirect URI. This is the URL Google sends the user back to after they approve access.
For this app, I used a route shaped like this:
/api/auth/google/callback
The actual full redirect URI in Google Cloud used the production domain and HTTPS. That detail caused one of the first failures. Locally, I had been thinking in terms of app routes. In production, Google only cares about the exact full URI. Scheme, domain, path, and trailing slash behavior all have to line up.
I also added the Search Console scope needed for the app. I started with the smallest permission set that matched the job. I did not want broad Google account access. The whole point is to read Search Console properties and data, not ask for anything unrelated.
Small win: once the redirect URI exactly matched production, the first major OAuth error disappeared immediately. No mystery fix. Just Google being strict, as it should be.
Building the connect route
The connect route is the user’s entry point. When they click “Connect Google Search Console,” the app builds a Google authorization URL and sends them there.
The URL includes the client ID, redirect URI, response type, scopes, access type, prompt behavior, and a state value. The state value is there to protect the flow from random callback attempts. I generate it before the redirect, store it temporarily, then compare it when Google sends the user back.
The two parameters that ended up mattering most during testing were access_type=offline and prompt=consent.
Without offline access, the app may only get a short-lived access token. That is fine for a quick demo, but not enough for a production integration that needs to fetch data later. The refresh token is what lets the app get a new access token without asking the user to reconnect every hour.
The prompt setting helped during repeat testing. Google does not always return a refresh token if the user has already approved the app before. For development and production verification, forcing the consent prompt made the behavior easier to confirm. Later, this can be tuned depending on the user experience I want.
Handling the callback
The callback route does the real work. Google sends the user back with a temporary code. The app checks the state value, then sends that code to Google’s token endpoint along with the client credentials and redirect URI.
If everything matches, Google returns token data. This usually includes an access token, an expiration time, and sometimes a refresh token. The app stores what it needs and marks the user’s Google Search Console connection as active.
I added logging around each stage, but kept the sensitive values out of the logs. That gave me enough visibility to debug the flow without dumping secrets into server output. The logs showed things like “OAuth callback received,” “state check passed,” “token exchange succeeded,” and “GSC connection saved.”
That made the production test much calmer. Instead of guessing where the flow died, I could see the last successful step.
The bugs that showed up in production
The production version broke in a few predictable places. Annoying, yes. Useful, also yes.
Redirect URI mismatch
This was the first hard stop. The app was sending a redirect URI that did not exactly match the one registered in Google Cloud. The fix was to stop assembling that value casually and instead use one production redirect URI from environment config.
Once I did that, the app and Google were reading from the same source of truth.
HTTPS and proxy confusion
The Pi sits behind HTTPS, but the app server itself may see traffic as plain HTTP depending on the proxy setup. That can create bad generated URLs if the app tries to infer the current protocol on its own.
I fixed this by being explicit with the public app URL and redirect URI. I also checked the proxy headers so the app understood the original request correctly where needed.
This is one of those production-only bugs that barely exists on localhost. Locally, everything feels direct. In production, the request path has more layers.
Refresh token missing
At one point, the token exchange succeeded but no refresh token came back. That looked like a failure until I remembered Google’s behavior around repeated consent. If a user has already granted access, Google may skip sending a refresh token on later approvals.
For testing, I used the consent prompt and also revoked the app from the Google account when I needed a clean run. After that, the refresh token appeared and storage worked as expected.
Cookie behavior after redirect
There was also a short round of cookie checking. OAuth bounces the user out to Google and back, so session and state handling need to survive that trip. Secure cookie settings, same-site behavior, and production HTTPS all matter here.
The fix was mostly config cleanup. Once the app treated production as HTTPS from the start, the state check stayed stable.
The confirmation test
After the fixes, I ran the full production flow from a normal browser session.
- Opened the production app on the Pi.
- Clicked the Google Search Console connect button.
- Landed on Google’s consent screen.
- Approved the requested Search Console access.
- Returned to the production callback route.
- Confirmed the token exchange completed.
- Checked that the connection was saved as active.
- Verified the refresh token was present.
That was the moment the feature moved from “wired together” to “actually working.” The app was no longer pretending to be connected. It had real authorization from Google and the stored token data needed for later Search Console pulls.
I also tested the failure path by denying access from Google. That came back cleanly too. Instead of throwing a messy server error, the app handled the denial and sent the user back with a useful message. That is easy to skip, but it makes the integration feel much less brittle.
How the token storage is shaped
I kept the token storage simple for this version. The app stores the provider name, access token, refresh token, expiration time, connection status, and the user or account it belongs to. That gives the app enough information to refresh access later and know whether the Search Console connection is usable.
I am treating the refresh token as the valuable part. Access tokens expire. Refresh tokens are what keep the connection alive. That means refresh tokens need to be handled carefully, stored server-side, and never exposed to the browser.
The next pass will likely include encryption at rest for stored tokens. For this milestone, the priority was proving the production OAuth flow from start to finish, but token handling is not an area to leave half-finished forever.
The checklist I’ll reuse next time
This is the checklist I wish I had written before starting. I’ll use it for the next Google integration too.
- Use one production app URL in environment config.
- Use one exact production OAuth redirect URI in environment config.
- Copy that exact URI into Google Cloud.
- Request only the scopes the app actually needs.
- Generate and verify a state value for every OAuth attempt.
- Use offline access when the app needs background data pulls.
- Do not log access tokens, refresh tokens, client secrets, or full callback payloads.
- Test the approval path and the denial path.
- Check refresh token behavior with a clean Google account grant.
- Confirm the whole thing from the production domain, not just localhost.
That last one is the big one. Local OAuth tests are useful, but they do not prove the production flow. The production domain, HTTPS layer, proxy behavior, cookies, and Google Cloud settings all need to agree.
What I learned from getting it live
The biggest lesson was to make production configuration boring. OAuth does not reward clever URL building. It rewards exact strings, stable environment variables, and patient testing.
I also learned to add logs before I feel stuck. Not noisy logs. Just enough breadcrumbs to know which stage passed. With OAuth, the difference between “Google rejected the request” and “my callback failed after Google returned the user” changes the entire debugging path.
Another lesson: build the smallest working loop first. I did not start by designing every future Search Console report. I started with the connection. Can the user approve access? Can the app receive the callback? Can it store tokens? Can it know the connection is active? Once that loop worked, the rest of the Search Console feature set became much easier to think about.
Running this on a Pi also keeps me honest. There is no giant platform hiding every deployment detail. If proxy headers are wrong, I see it. If environment variables are missing, I see it. If the app assumes localhost behavior in production, I see it fast.
Where this goes next
Now that Google Search Console OAuth is working in production, the next step is pulling actual Search Console data through the connected account. First I’ll fetch the available properties, then let the user pick which site they want the app to work with. After that, I can start bringing in search analytics data on a schedule.
I also want a simple disconnect flow. Users should be able to remove the connection from inside the app, and the app should clean up stored token data when they do. That is part of making the integration feel complete instead of just technically connected.
For now, the milestone is real: the production app on the Pi can send a user through Google OAuth, receive the callback, exchange the code, store the token data, and confirm the Search Console connection. That opens the door for the actual product feature I wanted in the first place.
This was a good reminder that progress often looks like one stubborn plumbing task finally working. No fireworks. Just a button, a consent screen, a callback, and a green connection state. I’ll take that win.