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_uribeing 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
Or this:
https://app.example.com/auth/slack/callback
vs
https://www.example.com/auth/slack/callback
Or this:
http://staging.example.com/auth/hubspot/callback
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
Generated by app code:
const redirectUri = `${process.env.PUBLIC_API_BASE_URL}/auth/google/callback`;
If PUBLIC_API_BASE_URL is accidentally set to:
PUBLIC_API_BASE_URL=https://api.example.com
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);
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);
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
});
If those two redirect_uri values differ, Slack is not the problem.
Your app is.
Another Slack gotcha: scopes are split
Slack also separates:
-
scopefor bot scopes -
user_scopefor 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);
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
# .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
# .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
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
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
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`;
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/
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");
});
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
docker compose restart
kubectl rollout restart deployment/my-api
kubectl rollout restart deployment/my-workers
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
Confirm what the app is actually sending
grep -R "redirect_uri" ./src
Inspect the callback route in logs
tail -f logs/app.log | grep callback
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
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();
});
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)