Skip to main content

Fixing 'invalid_grant' Errors: Why Your Google Ads API Refresh Token Expires Every 7 Days

 There is a specific, maddening scenario that plagues developers integrating with the Google Ads API. You build an automated reporting tool or a bid management script. You generate your OAuth2 credentials, perform the initial handshake, and everything runs perfectly.

Then, exactly 168 hours (7 days) later, your logs turn red. The error message is terse: invalid_grant. Your refresh token, which is supposed to provide long-term access, has stopped working. You generate a new one, and the cycle repeats a week later.

If you are encountering this strict 7-day expiration, your code is likely fine. The issue lies in your Google Cloud Project configuration. This guide details the root cause and provides the configuration changes required to secure a persistent refresh token.

The Root Cause: Google Cloud "Testing" Status

The behavior you are experiencing is not a bug; it is a security feature enforced by the Google Identity Platform.

When you create a new OAuth 2.0 Client ID in the Google Cloud Console, your OAuth Consent Screen defaults to a "Testing" publishing status.

Google’s documentation explicitly states:

"A Google Cloud Platform project with an OAuth consent screen configured for an external user type and a publishing status of 'Testing' is issued a refresh token expiring in 7 days."

While in "Testing" mode, Google allows you to add specific test users (email addresses) who can authorize your app. This is great for development but fatal for production automation. To get a refresh token that does not expire, you must promote your app's publishing status to "In production".

The Fix: Promoting Your App to Production

You do not need to go through the rigorous (and expensive) Google Security Assessment verification process if you are only using the app for internal scripts or personal data. You simply need to flip the switch to "Production."

Step 1: Access the OAuth Consent Screen

  1. Log in to the Google Cloud Console.
  2. Select the project associated with your Google Ads API credentials.
  3. In the left sidebar, navigate to APIs & Services > OAuth consent screen.

Step 2: Change Publishing Status

Look for the section labeled Publishing status.

  1. If it says Testing, click the button labeled PUBLISH APP.
  2. A modal will appear warning you that your app will be available to any user with a Google Account. Confirm the prompt.

Step 3: Handling Verification Status

Once you click "Publish," your status changes to In production. However, your verification status will likely say "Unverified."

  • For Internal/Private Use: If you are the only user (or your team is), you do not need to submit for verification.
  • The Trade-off: When you authenticate to generate the token (the first time), you will see a "Google hasn't verified this app" warning screen. You can bypass this by clicking Advanced > Go to {App Name} (unsafe).

Because the app is now "In production," the refresh token generated after this change will persist indefinitely (until manually revoked), solving the 7-day timeout.

Generating a Robust Refresh Token (Node.js)

Now that your configuration allows for persistent tokens, you need to generate one. Do not rely on temporary playground tools. Use a dedicated script to perform the OAuth flow. This ensures you request the correct scopes and access types.

Below is a modern TypeScript implementation using the official google-auth-library.

Prerequisites

npm install google-auth-library open

The Token Generation Script

Save this as generate-token.ts. This script spins up a temporary local server to capture the callback code automatically, preventing copy-paste errors.

import { OAuth2Client } from 'google-auth-library';
import http from 'http';
import url from 'url';
import open from 'open';
import { destroyer } from 'server-destroy';

// Configuration: Get these from Google Cloud Console > APIs & Services > Credentials
const CLIENT_ID = 'YOUR_CLIENT_ID.apps.googleusercontent.com';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
// This must exactly match the "Authorized redirect URI" in Cloud Console
const REDIRECT_URI = 'http://localhost:3000/oauth2callback';

// The scope required for Google Ads API
const SCOPES = ['https://www.googleapis.com/auth/adwords'];

async function main() {
  const oAuth2Client = new OAuth2Client(
    CLIENT_ID,
    CLIENT_SECRET,
    REDIRECT_URI
  );

  // Generate the url that will be used for the consent dialog.
  const authorizeUrl = oAuth2Client.generateAuthUrl({
    access_type: 'offline', // CRITICAL: 'offline' allows us to get a Refresh Token
    scope: SCOPES,
    prompt: 'consent', // Forces a new Refresh Token to be issued
  });

  console.log(`\n1. Opening browser to: ${authorizeUrl}\n`);
  
  // Open the browser to the authorize url to start the workflow
  await open(authorizeUrl);

  // Create a temporary server to accept the callback
  const server = http
    .createServer(async (req, res) => {
      try {
        if (req.url!.indexOf('/oauth2callback') > -1) {
          const qs = new url.URL(req.url!, 'http://localhost:3000')
            .searchParams;
          
          const code = qs.get('code');
          res.end('Authentication successful! Please return to the console.');
          server.destroy();
          
          if (code) {
             const { tokens } = await oAuth2Client.getToken(code);
             console.log('--- SUCCESS! ---');
             console.log('Refresh Token:', tokens.refresh_token);
             console.log('----------------');
             console.log('Add this token to your google-ads.yaml or environment variables.');
          }
        }
      } catch (e) {
        console.error('Error during callback:', e);
        res.end('Authentication failed.');
        server.destroy();
      }
    })
    .listen(3000, () => {
      console.log('2. Waiting for authentication...');
    });

  destroyer(server);
}

main().catch(console.error);

Run this script locally. Since you moved your app to "Production," the refresh_token output by this script will be valid indefinitely.

Technical Deep Dive: access_type and prompt

In the code above, two parameters are critical for avoiding invalid_grant errors in the future.

1. access_type: 'offline'

Standard OAuth flows return an Access Token (valid for 1 hour). To get a Refresh Token (which allows you to generate new Access Tokens without user interaction), you must explicitly request offline access.

2. prompt: 'consent'

If a user has already granted permission to your app, Google will not return a new refresh token by default—it will only return an access token.

By setting prompt: 'consent', you force the consent screen to appear again. This guarantees that the response payload includes a refresh_token. If you omit this, you might receive a payload with only an access_token, leading to integration failures down the line.

Common Pitfalls and Edge Cases

Even with "Production" status, a refresh token can still become invalid. Here is how to handle those edge cases.

The 50-Token Limit

There is a limit of 50 outstanding refresh tokens per user account per client ID. If you generate a 51st token, the oldest existing token is silently revoked.

  • Solution: Store your refresh token centrally (e.g., AWS Secrets Manager, HashiCorp Vault) and share it across your services, rather than generating a new token for every container instance or server.

Password Changes

If the user account associated with the refresh token changes their Google password, all refresh tokens for that user are immediately revoked.

  • Solution: Use a Service Account for purely server-to-server communication if possible (though this is complex with Google Ads API due to Domain-Wide Delegation requirements). Alternatively, use a dedicated "System User" Google account that is not used by humans, reducing the risk of password resets.

Inactive Token Revocation

If a refresh token is not used to generate an access token for six months, it may expire.

  • Solution: Ensure your automated jobs run at least once a week. This keeps the token active in Google's internal tracking systems.

Summary

The "7-day expiration" is a safeguard for testing environments that inadvertently breaks production automations.

  1. Go to Google Cloud Console.
  2. Set OAuth Consent Screen to "In production".
  3. Ignore the "Unverified" warning for internal tools.
  4. Generate a new token using access_type: 'offline' and prompt: 'consent'.

By moving out of the "Testing" sandbox, you ensure your automated bidding, reporting, and integration scripts remain stable without weekly maintenance.