DevOps

Google OAuth on a Headless VPS with an SSH Local Tunnel

Complete browser-based Google OAuth on a VPS without exposing the callback publicly, using loopback redirects, SSH forwarding and safe token handling.

Abstract secure tunnel connecting a laptop browser to a loopback OAuth callback on a remote server
AgentPedia illustration of a browser authorization callback carried through a loopback-only SSH tunnel. View image source.

Browser-based OAuth is designed around a user who can sign in and approve access. A VPS is designed around a process with no desktop. The bridge is an SSH local-forwarding tunnel: the browser remains on the laptop, while the callback listener remains private on the VPS. For a broader remote-agent tunnel threat model, see the secure MCP tunnels guide.

This guide focuses on Google’s authorization-code flow, loopback callback design and OpenSSH controls. For broader secure tunnel patterns, see the OpenAI secure MCP tunnels guide.

The practical verdict

Use this topology when the application runs on a remote server but Google login must happen in a user-controlled browser. It avoids putting a public callback endpoint on the VPS and avoids giving a bot your Google password.

The critical invariant is exactness: the redirect URI registered in Google Cloud, the application listener and the laptop-side tunnel must agree on host, port and path.

Understand the topology

Chrome on laptop
  http://127.0.0.1:8080/oauth2callback
          |
          | SSH local forward: laptop:8080 -> VPS:127.0.0.1:8080
          v
OAuth app on VPS
  listens only on 127.0.0.1:8080
          |
          v
Google authorization and token endpoints over HTTPS

The browser opens Google’s HTTPS consent page. After approval, Google redirects to the registered loopback callback. The browser connects to the laptop’s local port, and SSH carries the request to the VPS listener.

The callback is not copied into chat. The authorization code is consumed by the application and exchanged directly with Google.

Configure Google OAuth

Google’s web-server OAuth documentation requires the redirect URI to match exactly. Register a URI such as:

http://127.0.0.1:8080/oauth2callback

Use localhost everywhere instead if that is what the application and client use. Do not mix localhost and 127.0.0.1 casually.

Request only the scopes required by the feature. Google recommends incremental authorization and the narrowest scopes possible. Analytics and Search Console read-only access should not automatically become Gmail or Drive access.

The authorization request should use an unpredictable state value, store it server-side and compare it before exchanging the callback code. For refresh-token use when the user is absent, Google documents access_type=offline. A refresh token may not be returned on every later authorization.

Sensitive or restricted scopes can trigger Google verification requirements. Personal, internal, development and testing exceptions vary, so check Google’s current OAuth policies before production launch.

Create the SSH tunnel

On the laptop, run:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -L 8080:127.0.0.1:8080 \
  user@YOUR_VPS_HOST

-N creates a forwarding-only session. ExitOnForwardFailure=yes prevents a misleading tunnel process from remaining after the local bind fails. Explicitly binding the laptop side to loopback avoids exposing the callback to the laptop’s network interfaces.

OpenSSH’s -L syntax forwards a local port to a host and port reachable from the remote side. The destination 127.0.0.1:8080 therefore refers to the VPS loopback interface, not the laptop.

Do not add -g or bind the local side to 0.0.0.0 for an OAuth callback. The callback should be reachable only by the browser on the laptop.

Complete the callback

In a second VPS session, start the application’s login command. A headless-friendly CLI should print a short-lived Google authorization URL instead of requiring a local GUI browser:

# example application command
aitraffic auth google login --profile work

If the application currently attempts to open xdg-open, that is harmless on a headless VPS as long as it also prints the URL. Copy the URL from the VPS terminal into Chrome on the laptop and complete consent yourself.

The application should then:

  1. receive the loopback callback through SSH;
  2. validate state;
  3. reject an OAuth error or missing code;
  4. exchange the code over HTTPS;
  5. verify the granted scopes and account identity;
  6. store the refresh token securely;
  7. show success without printing the token.

If the callback page never loads, check the local listener, tunnel process, exact port and registered redirect URI. Do not paste the callback URL into a chat system to “finish” the login.

Protect tokens and scopes

Google’s OAuth policy requires secure token handling. Keep these private:

  • OAuth client JSON and client secret;
  • authorization URL if it contains state and client details;
  • authorization code;
  • callback URL;
  • access token and refresh token;
  • account identifiers and scope grants.

Store tokens encrypted at rest or in the operating system credential store. Restrict the token file or vault to the service account. Never commit it, include it in a bug report or pass it through an agent prompt.

Handle invalid_grant as a possible revocation or expired refresh token and require a new browser authorization. Revoke and delete tokens when the integration is removed. Do not silently broaden scopes during reauthorization.

Handle failures

SymptomLikely causeSafe response
redirect_uri_mismatchHost, port, path or trailing slash differsCompare Google Cloud, app and tunnel character by character
Browser connection refusedTunnel or VPS listener is absentCheck both processes; do not expose a public port as a shortcut
OAuth state failureWrong/stale callback or interrupted flowAbort and restart; never exchange a code after state mismatch
invalid_grantRevoked, expired or reused code/tokenReauthorize in the browser and rotate affected credentials
Consent deniedUser or policy rejected a scopeRequest less scope or resolve verification; do not retry blindly
Tunnel dropsSSH/network interruptionReconnect and restart the short-lived login flow
Port already usedExisting listener or tunnelStop the intended disposable process or choose a reviewed port

SSH forwarding can also be restricted by AllowTcpForwarding, PermitOpen and DisableForwarding in sshd_config. If a tunnel fails on a locked-down VPS, review the server policy rather than weakening it globally.

Security checklist

  • [ ] OAuth client JSON is stored only on the VPS, not in chat.
  • [ ] Redirect URI matches exactly.
  • [ ] Listener binds only to VPS loopback.
  • [ ] Laptop forward binds only to laptop loopback.
  • [ ] SSH uses a dedicated key and restricted account where possible.
  • [ ] ExitOnForwardFailure and keepalives are enabled.
  • [ ] OAuth state is generated, stored and validated.
  • [ ] Scopes are minimal and read-only where possible.
  • [ ] Tokens are encrypted or stored in a credential vault.
  • [ ] Callback URLs and authorization codes are never pasted into an agent.
  • [ ] Revocation and reauthorization are tested.

This flow gives the user control of Google login while keeping the server-side callback private. It is simple, auditable and safer than exposing a temporary public OAuth endpoint.

FAQ

The concise answers are in metadata; the topology, callback and token rules above are the operational guide.

Official sources

Related Guides