← All guides

Testing OAuth callbacks on localhost

Set up Google, GitHub, and generic OAuth flows against localhost using a tunnel with stable redirect URIs.

Why localhost breaks OAuth

OAuth 2.0 works by redirecting the user to a provider (Google, GitHub, etc.), letting them authorize your app, and then redirecting them back to your server with an authorization code. That "redirect back" step is where localhost falls apart.

The redirect URI you register with the provider must exactly match the one your app uses at runtime. If you register http://localhost:3000/auth/callback and the browser is actually on https://yourapp.example.com/auth/callback, the provider rejects the request with a redirect_uri_mismatch error.

Most providers also require HTTPS on redirect URIs for web applications. http://localhost:3000 is plain HTTP. Google makes a special exception for localhost in development mode, but GitHub, Microsoft, Slack, and many others do not. Even with Google, the exception only works for "installed app" type credentials, not "web application" credentials.

This leaves you with bad options: deploy to a staging server every time you change your auth code, or maintain a parallel localhost-specific OAuth configuration that doesn't match production. Both waste time and introduce bugs that only show up in one environment.

A tunnel with a stable subdomain sidesteps the whole problem:

curl -fsSL https://taupi.dev/install.sh | sh
taupi tunnel --subdomain myapp 3000

Now your local server is reachable at https://myapp.taupi.dev. Register that as your redirect URI. It's HTTPS, it's publicly reachable, and it's the same URL every time you start the tunnel. The provider doesn't know or care that the traffic ends up on your laptop.

Stable subdomains for consistent redirect URIs

OAuth providers are strict about redirect URI matching. A single character difference -- trailing slash, different subdomain, HTTP vs HTTPS -- causes a mismatch error. Random tunnel subdomains that change on every restart mean you'd have to update your provider dashboard every time you restart the tunnel. That defeats the purpose.

Claim a subdomain on taupi Pro so the URL never changes:

taupi tunnel --subdomain myapp 3000
# Always https://myapp.taupi.dev

Register https://myapp.taupi.dev/api/auth/callback/google (or whatever your callback path is) once in the provider's dashboard. Restart the tunnel, reboot your machine, come back the next week -- the redirect URI still works.

Your .env.local or equivalent config file should reference the tunnel URL:

# .env.local
BASE_URL=https://myapp.taupi.dev
NEXTAUTH_URL=https://myapp.taupi.dev
GOOGLE_REDIRECT_URI=https://myapp.taupi.dev/api/auth/callback/google
GITHUB_REDIRECT_URI=https://myapp.taupi.dev/api/auth/callback/github

Google OAuth with Next.js and NextAuth.js

Configure the Google Cloud Console

  1. Go to Google Cloud Console > APIs & Services > Credentials.
  2. Click "Create Credentials" > "OAuth client ID".
  3. Select Web application as the application type.
  4. Under Authorized JavaScript origins, add:
    https://myapp.taupi.dev
    
  5. Under Authorized redirect URIs, add:
    https://myapp.taupi.dev/api/auth/callback/google
    
  6. Click "Create". Copy the client ID and client secret.

Google propagates credential changes immediately for development projects. You do not need to wait for anything to take effect. If you are working with a Google Workspace project that has a published OAuth consent screen, changes to the consent screen itself can take longer, but redirect URI changes are instant.

One thing to watch: Google lists authorized redirect URIs and authorized JavaScript origins as separate fields. You need to add the tunnel URL to both. Missing the JavaScript origins field causes a subtle failure where the consent screen loads but the redirect fails.

The NextAuth.js handler

// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";

const handler = NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      // Persist the OAuth access token in the JWT on first sign-in
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;
      }
      return token;
    },
    async session({ session, token }) {
      // Expose the access token to the client if needed
      session.accessToken = token.accessToken as string;
      return session;
    },
    async redirect({ url, baseUrl }) {
      // Ensure redirects stay within the tunnel domain
      if (url.startsWith(baseUrl)) return url;
      if (url.startsWith("/")) return baseUrl + url;
      return baseUrl;
    },
  },
  // Use the tunnel URL as the canonical base
  // Without this, NextAuth may generate localhost callback URLs
  secret: process.env.NEXTAUTH_SECRET,
});

export { handler as GET, handler as POST };

Environment variables

# .env.local
GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
NEXTAUTH_URL=https://myapp.taupi.dev
NEXTAUTH_SECRET=any-random-string-for-jwt-signing

NEXTAUTH_URL is critical. NextAuth uses it to construct the redirect URI sent to Google. If NEXTAUTH_URL is http://localhost:3000 but the browser is on https://myapp.taupi.dev, Google receives a redirect URI that doesn't match what you registered. Set it to the tunnel URL.

Start your dev server and the tunnel, then visit https://myapp.taupi.dev/api/auth/signin. Click "Sign in with Google", authorize the app, and Google redirects back to https://myapp.taupi.dev/api/auth/callback/google. NextAuth exchanges the code for tokens and creates a session.

# Terminal 1
npm run dev

# Terminal 2
taupi tunnel --subdomain myapp 3000

GitHub OAuth with Express and Passport

Configure GitHub Developer Settings

  1. Go to GitHub > Settings > Developer settings > OAuth Apps.
  2. Click "New OAuth App".
  3. Fill in the fields:
    • Application name: whatever you want (e.g., "myapp-dev")
    • Homepage URL: https://myapp.taupi.dev
    • Authorization callback URL: https://myapp.taupi.dev/auth/callback/github
  4. Click "Register application". Copy the client ID, then generate a client secret.

GitHub OAuth Apps only support a single callback URL. You cannot add multiple redirect URIs to one app the way Google allows. If you need different callback URLs for development and production, create separate OAuth Apps -- one for dev with the tunnel URL, one for production with the production URL. This is the standard practice; GitHub expects it.

The Express + Passport handler

import express from "express";
import session from "express-session";
import passport from "passport";
import { Strategy as GitHubStrategy } from "passport-github2";

const app = express();

// Session setup -- required for Passport to persist login state
app.use(
  session({
    secret: process.env.SESSION_SECRET || "dev-session-secret",
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: true, // tunnel is HTTPS, so this must be true
      sameSite: "lax",
      // Do NOT set domain -- let the browser default to the request origin
      // Setting domain to "taupi.dev" will not work because you don't own the parent domain
    },
  })
);

app.use(passport.initialize());
app.use(passport.session());

passport.serializeUser((user: any, done) => {
  done(null, user);
});

passport.deserializeUser((obj: any, done) => {
  done(null, obj);
});

passport.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: "https://myapp.taupi.dev/auth/callback/github",
      scope: ["user:email", "read:user"],
    },
    (
      accessToken: string,
      refreshToken: string,
      profile: any,
      done: Function
    ) => {
      // In production, look up or create a user in your database
      // For development, just pass the profile through
      console.log(`GitHub login: ${profile.username} (${profile.emails?.[0]?.value})`);
      return done(null, { ...profile, accessToken });
    }
  )
);

// Redirect to GitHub for authorization
app.get("/auth/github", passport.authenticate("github"));

// GitHub redirects back here with a code
app.get(
  "/auth/callback/github",
  passport.authenticate("github", { failureRedirect: "/login" }),
  (req, res) => {
    console.log("GitHub auth successful, redirecting to dashboard");
    res.redirect("/dashboard");
  }
);

// Protected route example
app.get("/dashboard", (req, res) => {
  if (!req.isAuthenticated()) {
    return res.redirect("/auth/github");
  }
  res.json({
    message: "Authenticated",
    user: (req.user as any).username,
  });
});

app.get("/login", (req, res) => {
  res.send('<a href="/auth/github">Login with GitHub</a>');
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

The callbackURL in the Passport strategy config must exactly match the URL registered in GitHub. A trailing slash mismatch (/github vs /github/) triggers a redirect URI error. Copy the URL from GitHub rather than typing it by hand.

# .env
GITHUB_CLIENT_ID=Iv1.abc123def456
GITHUB_CLIENT_SECRET=your_secret_here
SESSION_SECRET=any-random-string

Generic PKCE authorization code flow

If you're not using a framework like NextAuth or Passport, or you're integrating with a provider that doesn't have a library, you'll implement the OAuth 2.0 authorization code flow with PKCE directly. PKCE (Proof Key for Code Exchange) is required by many providers for public clients and recommended for all clients.

Build the authorization URL

import crypto from "crypto";

function base64url(buffer: Buffer): string {
  return buffer.toString("base64url");
}

function generatePKCE() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(
    crypto.createHash("sha256").update(verifier).digest()
  );
  return { verifier, challenge };
}

// Generate PKCE values and state
const { verifier, challenge } = generatePKCE();
const state = base64url(crypto.randomBytes(16));

// Store these server-side, keyed by state, so you can validate on callback
stateStore.set(state, { verifier, createdAt: Date.now() });

// Build the authorization URL
const authUrl = new URL("https://provider.example.com/oauth/authorize");
authUrl.searchParams.set("client_id", process.env.OAUTH_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://myapp.taupi.dev/auth/callback");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");

// Redirect the user to authUrl.toString()

Exchange the code for tokens

import express from "express";

const app = express();

app.get("/auth/callback", async (req, res) => {
  const { code, state } = req.query as { code: string; state: string };

  // Validate the state parameter to prevent CSRF
  const stored = stateStore.get(state);
  if (!stored) {
    return res.status(403).send("Invalid or expired state parameter");
  }
  stateStore.delete(state);

  // Check for staleness (e.g., reject if older than 10 minutes)
  if (Date.now() - stored.createdAt > 600_000) {
    return res.status(403).send("Authorization request expired");
  }

  // Exchange the authorization code for tokens
  const tokenResponse = await fetch("https://provider.example.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
      code,
      redirect_uri: "https://myapp.taupi.dev/auth/callback",
      code_verifier: stored.verifier,
    }),
  });

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    console.error("Token exchange failed:", error);
    return res.status(502).send("Token exchange failed");
  }

  const tokens = await tokenResponse.json();
  console.log("Access token:", tokens.access_token);
  console.log("Refresh token:", tokens.refresh_token);
  console.log("Expires in:", tokens.expires_in, "seconds");

  // Store tokens in your session or database
  req.session.tokens = tokens;
  res.redirect("/dashboard");
});

app.listen(3000);

The redirect_uri in the token exchange request must exactly match the one used in the authorization request. The OAuth 2.0 spec (RFC 6749 Section 4.1.3) requires this, and providers enforce it strictly. If you copy-paste incorrectly and end up with https://myapp.taupi.dev/auth/callback in one place and https://myapp.taupi.dev/auth/callback/ (with trailing slash) in the other, the token exchange fails.

Cookie and session gotchas

OAuth flows depend on cookies for session management and CSRF protection. Tunnels introduce some tricky interactions.

SameSite attribute

The SameSite attribute on cookies controls whether they're sent on cross-origin requests. During an OAuth flow, the browser navigates away to the provider and then back to your server -- that final redirect is a cross-site navigation.

app.use(
  session({
    secret: "your-secret",
    cookie: {
      sameSite: "lax", // "lax" allows cookies on top-level navigations (redirects)
      secure: true,    // tunnel is HTTPS, so Secure must be true
    },
  })
);

SameSite: "lax" is the right setting for OAuth callbacks. It allows the cookie to be sent when the user clicks a link or is redirected from another site (the OAuth provider) back to yours. SameSite: "strict" would block the cookie on the callback redirect, causing your session to be lost. SameSite: "none" works but exposes the cookie to all cross-site requests, which is unnecessary.

Secure flag

Your tunnel serves HTTPS, so cookies with Secure: true work correctly -- the browser sees an HTTPS origin and sends the cookie. But if your code checks req.protocol to decide whether to set the Secure flag, it might see http because the taupi client connects to your local server over plain HTTP.

Fix this by trusting the proxy headers that taupi forwards:

// Express
app.set("trust proxy", 1);

// Now req.protocol reflects the original request (https)
// and req.secure is true

Without this, Express sees the local HTTP connection and may set Secure: false, which means the cookie gets set but then isn't sent back on subsequent HTTPS requests through the tunnel.

Domain attribute

Do not set the domain attribute on your cookies to taupi.dev or myapp.taupi.dev. The browser enforces that the domain attribute must be a parent domain of the request's host. Since you don't own taupi.dev, the browser may reject the cookie silently.

// WRONG
cookie: {
  domain: "taupi.dev", // you don't own this domain
  secure: true,
}

// WRONG
cookie: {
  domain: ".myapp.taupi.dev", // unnecessary and fragile
  secure: true,
}

// RIGHT
cookie: {
  secure: true,
  // omit domain entirely -- browser defaults to the exact request origin
}

Omitting the domain attribute is almost always what you want for development. The browser defaults to the exact host of the response, which is your tunnel subdomain.

Managing multiple environments

You'll typically have at least two environments: local development (via tunnel) and production. Some teams add a staging environment too. Each environment needs its own OAuth credentials or at least its own redirect URIs.

Separate OAuth apps per environment

For providers like GitHub that only support one redirect URI per OAuth app, create separate apps:

GitHub OAuth App: "myapp-dev"
  Callback URL: https://myapp.taupi.dev/auth/callback/github

GitHub OAuth App: "myapp-production"
  Callback URL: https://myapp.example.com/auth/callback/github

Use environment-specific .env files to load the right credentials:

# .env.development
GITHUB_CLIENT_ID=Iv1.dev_client_id
GITHUB_CLIENT_SECRET=dev_secret
BASE_URL=https://myapp.taupi.dev

# .env.production
GITHUB_CLIENT_ID=Iv1.prod_client_id
GITHUB_CLIENT_SECRET=prod_secret
BASE_URL=https://myapp.example.com

Multiple redirect URIs in one app

Google allows multiple redirect URIs on a single OAuth app. You can add both your tunnel and production URLs:

Authorized redirect URIs:
  https://myapp.taupi.dev/api/auth/callback/google
  https://myapp.example.com/api/auth/callback/google
  https://staging.example.com/api/auth/callback/google

This lets you share a single client ID across environments. The client secret is the same, but the redirect URI your app sends in the authorization request determines which one Google matches against. Make sure your app constructs the redirect URI dynamically from the BASE_URL or NEXTAUTH_URL environment variable:

const callbackUrl = `${process.env.BASE_URL}/api/auth/callback/google`;

Keeping callback URLs in sync

A pattern that prevents redirect URI mismatches: derive the callback URL from a single environment variable rather than hardcoding it in multiple places.

// config.ts
export const config = {
  baseUrl: process.env.BASE_URL || "http://localhost:3000",
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    get callbackUrl() {
      return `${config.baseUrl}/api/auth/callback/google`;
    },
  },
  github: {
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    get callbackUrl() {
      return `${config.baseUrl}/auth/callback/github`;
    },
  },
};
# Development
BASE_URL=https://myapp.taupi.dev npm run dev

# Production
BASE_URL=https://myapp.example.com npm start

This way, changing BASE_URL updates every callback URL in the app. No more hunting through your code for hardcoded tunnel URLs that should have been changed to production URLs.