DEV Community

Cover image for OAuth redirect URI mismatch ate more time than my agent logic — 5 setup changes that finally fixed Google, Slack, and HubSpot
Lars Winstand
Lars Winstand

Posted on Originally published at standardcompute.com

OAuth redirect URI mismatch ate more time than my agent logic — 5 setup changes that finally fixed Google, Slack, and HubSpot

I lost more time to oauth redirect uri mismatch than to the actual agent logic.

Not the Gmail classification prompt.
Not the Slack bot scopes.
Not the HubSpot sync code.

Just OAuth.

The pattern was always the same:

  • localhost worked
  • staging mostly worked
  • production failed with a vague callback error

If you're building AI automations with Node.js, n8n, Slack apps, HubSpot apps, or Google integrations, this usually isn't random bad luck.

It's architecture.

The fix for us was boring but effective:

  • one credential set per environment
  • exact callback registration for each provider
  • stable public base URLs
  • logging the actual redirect_uri being sent
  • restarting workers after config changes

Once we did that, the "mystery OAuth bug" disappeared.

The real problem: your callback URL is telling two different stories

Most teams assume OAuth failures mean the integration logic is broken.

So they start debugging:

  • prompt formatting
  • token storage
  • webhook handling
  • SDK versions
  • queue workers

Meanwhile the actual problem is usually this:

Expected redirect URI:
https://app.example.com/auth/google/callback

Actual redirect URI sent:
https://api.example.com/auth/google/callback
Enter fullscreen mode Exit fullscreen mode

Or this:

https://app.example.com/auth/slack/callback
Enter fullscreen mode Exit fullscreen mode

vs

https://www.example.com/auth/slack/callback
Enter fullscreen mode Exit fullscreen mode

Or this:

http://staging.example.com/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode

when the provider expects HTTPS.

OAuth is extremely literal.

Your app can be conceptually correct and still fail because one URL string doesn't match exactly.

What bit us across Google, Slack, and HubSpot

Here's the short version.

Provider What bites builders most often
Google OAuth 2.0 for Web Server Apps Redirect URI must match exactly. Teams also confuse localhost/native app patterns with real web-server OAuth.
Slack OAuth v2 If you send redirect_uri in the authorize step, you must send the exact same redirect_uri again during token exchange.
HubSpot OAuth 2.0 redirect_uri is required, production redirects must use HTTPS, and app install often fails because the user is not a Super Admin.

None of these providers are especially weird.

The weirdness comes from our stack:

  • frontend on one domain
  • API on another
  • reverse proxy in front
  • background workers with stale env vars
  • an automation tool generating callback URLs from the wrong base URL

That combination creates bugs that feel random until you log the exact values.

1) Google: exact match means exact match

Google was the easiest to misunderstand because local prototypes create bad habits.

A lot of teams build something that works on localhost, then move it behind Vercel, Nginx, Cloud Run, or a custom Express server and assume the OAuth flow will survive the move.

Sometimes it doesn't.

For Google web-server OAuth, the registered redirect URI must match the one you send exactly.

Not "same route, different host." Not "same callback, but HTTPS gets added later by the proxy."

Exact.

Example

Registered in Google Cloud Console:

https://app.example.com/auth/google/callback
Enter fullscreen mode Exit fullscreen mode

Generated by app code:

const redirectUri = `${process.env.PUBLIC_API_BASE_URL}/auth/google/callback`;
Enter fullscreen mode Exit fullscreen mode

If PUBLIC_API_BASE_URL is accidentally set to:

PUBLIC_API_BASE_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

then your flow is dead before your agent code runs.

A simple Node example

import { google } from "googleapis";

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

const authUrl = oauth2Client.generateAuthUrl({
  access_type: "offline",
  scope: [
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/drive.readonly"
  ],
  prompt: "consent"
});

console.log("Google auth URL:", authUrl);
console.log("Google redirect URI:", process.env.GOOGLE_REDIRECT_URI);
Enter fullscreen mode Exit fullscreen mode

That last log line matters more than people think.

2) Slack: the same redirect URI has to appear twice

Slack was the one that felt unfair until I reread the docs carefully.

The trap is simple:

  • you send users to Slack's authorize URL
  • you include redirect_uri
  • Slack sends back a code
  • your backend exchanges the code for a token
  • but your backend uses a different redirect_uri, or omits it

Result: failure.

If you include redirect_uri during authorization, Slack expects the exact same value during token exchange.

Authorize step

const params = new URLSearchParams({
  client_id: process.env.SLACK_CLIENT_ID,
  scope: "chat:write,channels:history",
  redirect_uri: process.env.SLACK_REDIRECT_URI
});

const url = `https://slack.com/oauth/v2/authorize?${params.toString()}`;
console.log(url);
Enter fullscreen mode Exit fullscreen mode

Token exchange step

const body = new URLSearchParams({
  client_id: process.env.SLACK_CLIENT_ID,
  client_secret: process.env.SLACK_CLIENT_SECRET,
  code,
  redirect_uri: process.env.SLACK_REDIRECT_URI
});

const response = await fetch("https://slack.com/api/oauth.v2.access", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded"
  },
  body
});
Enter fullscreen mode Exit fullscreen mode

If those two redirect_uri values differ, Slack is not the problem.

Your app is.

Another Slack gotcha: scopes are split

Slack also separates:

  • scope for bot scopes
  • user_scope for user scopes

I've seen teams mis-spec scopes, then blame OAuth in general.

That bug looks a lot like redirect trouble at first because the install flow still fails.

3) HubSpot: sometimes the URL is fine and the installer is wrong

HubSpot gave us the most annoying fake OAuth bug.

The flow itself is straightforward:

  • send user to HubSpot authorize URL
  • user approves scopes
  • HubSpot redirects back with code
  • backend exchanges code for token

Basic example:

const params = new URLSearchParams({
  client_id: process.env.HUBSPOT_CLIENT_ID,
  redirect_uri: process.env.HUBSPOT_REDIRECT_URI,
  scope: "crm.objects.contacts.read crm.objects.contacts.write",
  state: crypto.randomUUID()
});

const installUrl = `https://app.hubspot.com/oauth/authorize?${params.toString()}`;
console.log(installUrl);
Enter fullscreen mode Exit fullscreen mode

The obvious rule is that redirect_uri is required.

The less obvious operational rule is that production redirects need HTTPS.

But the one that wasted the most time for us was permissions.

A user often needs Super Admin rights to install the app in a HubSpot account.

So if your rollout fails, don't immediately assume:

  • your token exchange code is wrong
  • your callback route is broken
  • your state parameter failed

Sometimes the installer just isn't allowed to approve the app.

That is not a code bug. It's an account-role problem wearing a code-bug costume.

The 5 setup changes that finally fixed it

This is the part I wish someone had handed me on day one.

1. One credential set per environment

Stop trying to make one OAuth app serve localhost, staging, and production cleanly.

Use separate credentials.

Examples:

  • Google OAuth client for local
  • Google OAuth client for staging
  • Google OAuth client for prod
  • separate Slack app config or redirect setup per environment
  • separate HubSpot app settings where needed

Example env files

# .env.local
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
SLACK_REDIRECT_URI=http://localhost:3000/auth/slack/callback
HUBSPOT_REDIRECT_URI=http://localhost:3000/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode
# .env.staging
GOOGLE_REDIRECT_URI=https://staging.example.com/auth/google/callback
SLACK_REDIRECT_URI=https://staging.example.com/auth/slack/callback
HUBSPOT_REDIRECT_URI=https://staging.example.com/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode
# .env.production
GOOGLE_REDIRECT_URI=https://app.example.com/auth/google/callback
SLACK_REDIRECT_URI=https://app.example.com/auth/slack/callback
HUBSPOT_REDIRECT_URI=https://app.example.com/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode

This feels tedious right up until it saves you two days.

2. Register every exact callback URL

Not just the base domain.

Not just one callback path you hope can cover everything.

Register the exact callback URLs the provider will see.

http://localhost:3000/auth/google/callback
https://staging.example.com/auth/google/callback
https://app.example.com/auth/google/callback
https://app.example.com/auth/slack/callback
https://app.example.com/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode

If your app uses different subdomains for API and UI, decide which one owns the callback and stick to it.

3. Use one stable public base URL

A lot of OAuth bugs are really bad URL generation.

The app builds redirect URIs from:

  • request headers
  • internal container hostnames
  • editor URLs
  • proxy-forwarded hosts
  • stale env vars

That is how you end up with callbacks pointing at the wrong domain.

I prefer making the public base URL explicit.

Example

PUBLIC_APP_URL=https://app.example.com
PUBLIC_API_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

Then build callbacks from one canonical value:

const GOOGLE_REDIRECT_URI = `${process.env.PUBLIC_APP_URL}/auth/google/callback`;
const SLACK_REDIRECT_URI = `${process.env.PUBLIC_APP_URL}/auth/slack/callback`;
const HUBSPOT_REDIRECT_URI = `${process.env.PUBLIC_APP_URL}/auth/hubspot/callback`;
Enter fullscreen mode Exit fullscreen mode

If you're using n8n or similar tools, the public URL config matters a lot.

Example:

export VUE_APP_URL_BASE_API=https://n8n.example.com/
Enter fullscreen mode Exit fullscreen mode

If the editor thinks it's running at one URL and the backend tells Google or Slack another story, you'll keep chasing ghosts.

4. Log the outbound authorize URL and inbound callback details

This was the biggest practical win.

Log:

  • full authorize URL
  • exact redirect_uri
  • exact state
  • callback host/path/query
  • token exchange payload fields

Not secrets, obviously.

Just enough to compare what you intended with what actually happened.

Express middleware example

app.get("/auth/google/start", (req, res) => {
  const redirectUri = process.env.GOOGLE_REDIRECT_URI;
  const state = crypto.randomUUID();

  const authUrl = oauth2Client.generateAuthUrl({
    access_type: "offline",
    scope: ["https://www.googleapis.com/auth/gmail.readonly"],
    state
  });

  console.log("[google:start] redirect_uri=", redirectUri);
  console.log("[google:start] state=", state);
  console.log("[google:start] authUrl=", authUrl);

  res.redirect(authUrl);
});

app.get("/auth/google/callback", (req, res) => {
  console.log("[google:callback] host=", req.get("host"));
  console.log("[google:callback] originalUrl=", req.originalUrl);
  console.log("[google:callback] query=", req.query);

  res.send("ok");
});
Enter fullscreen mode Exit fullscreen mode

The first time you compare these logs across local, staging, and prod, the mismatch usually becomes obvious.

5. Restart workers after config changes

This sounds embarrassingly basic because it is.

But long-running processes love stale config:

  • queue consumers
  • webhook handlers
  • background job runners
  • AI agent workers

You update the dashboard or environment variables, but a worker still has the old redirect base cached in memory.

Typical restart commands

pm2 restart all
Enter fullscreen mode Exit fullscreen mode
docker compose restart
Enter fullscreen mode Exit fullscreen mode
kubectl rollout restart deployment/my-api
kubectl rollout restart deployment/my-workers
Enter fullscreen mode Exit fullscreen mode

We had workers generating old callback domains hours after the fix was supposedly deployed.

That bug felt supernatural until we realized the processes had never reloaded.

A quick debugging checklist

When OAuth fails, this is the checklist I use now.

Redirect URI sanity check

echo $GOOGLE_REDIRECT_URI
echo $SLACK_REDIRECT_URI
echo $HUBSPOT_REDIRECT_URI
Enter fullscreen mode Exit fullscreen mode

Confirm what the app is actually sending

grep -R "redirect_uri" ./src
Enter fullscreen mode Exit fullscreen mode

Inspect the callback route in logs

tail -f logs/app.log | grep callback
Enter fullscreen mode Exit fullscreen mode

Verify HTTPS in production

curl -I https://app.example.com/auth/google/callback
curl -I https://app.example.com/auth/slack/callback
curl -I https://app.example.com/auth/hubspot/callback
Enter fullscreen mode Exit fullscreen mode

Check for proxy/header weirdness

app.set("trust proxy", true);

app.use((req, _res, next) => {
  console.log({
    protocol: req.protocol,
    host: req.get("host"),
    forwardedProto: req.get("x-forwarded-proto"),
    forwardedHost: req.get("x-forwarded-host")
  });
  next();
});
Enter fullscreen mode Exit fullscreen mode

If your app is behind a proxy and doesn't trust forwarded headers correctly, it may generate the wrong public URL.

One question that can remove a lot of OAuth pain

Do you actually need user OAuth?

A lot of internal automations don't.

If you're acting on shared infrastructure or project-owned resources, service accounts can be much simpler than user-consent flows.

That won't replace Slack workspace installs or HubSpot app installs, obviously.

But for some Google workflows, it's the difference between:

  • handling consent screens
  • storing refresh tokens
  • managing callback URLs

and just using system-level credentials.

Ask that question early.

It can save a stupid amount of time.

Why this matters more for AI agents and automations

This problem gets worse when your stack includes:

  • n8n
  • Make
  • Zapier
  • custom Node.js backends
  • Slack bots
  • CRM sync workers
  • Gmail or Drive agents
  • long-running background jobs

Agent systems are already distributed.

Now add OAuth on top and every blurry architecture boundary gets exposed immediately.

That's why these bugs feel so expensive.

You're not just debugging one app.

You're debugging identity across multiple services, multiple environments, and multiple callback assumptions.

The lesson I took from this

OAuth isn't hard because the protocol is mysterious.

It's hard because it forces your architecture to stop lying.

If local, staging, and production are fuzzy, OAuth will find the fuzziness.

If your callback URL depends on whichever host answered the request, OAuth will punish that.

If your HubSpot installer doesn't have Super Admin rights, no amount of token debugging will save you.

The practical fix is boring:

  • separate credentials by environment
  • register exact callback URLs
  • use a stable public base URL
  • log what you actually send
  • restart every process that might cache config

Do that, and your agents can go back to failing for interesting reasons.


One last thing: this is exactly the kind of problem that gets worse when every retry, every background worker run, and every agent loop has a per-token or per-call cost attached.

If you're running lots of AI automations and agent workflows, predictable infrastructure matters just as much as correct OAuth setup. That's a big part of why Standard Compute exists: flat monthly AI compute for teams running automations all day, without watching token burn every time a workflow retries.

If that's your situation, Standard Compute is worth a look: https://standardcompute.com

Top comments (0)