GC OAuth Token Expiry in CI/CD Pipeline

  • Environment: GitHub Actions, Node.js 18
  • Framework: Angular 15 Agent Desktop
  • Auth: Service Account
POST /oauth/token
401 Unauthorized
{ "error": "invalid_grant" }

The short-lived token expires during our 15-minute build. I need a long-lived token for the pipeline. The documentation suggests using refresh_token with a service account, but the initial grant does not return a refresh token. Is there a specific scope or grant type required to obtain a refreshable token for non-interactive CI/CD flows?

If I remember correctly, error: 401 invalid_grant happens because you’re missing the offline_access scope in your initial client_credentials request. Service accounts require this scope to issue a refresh token.

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id={{clientId}}&client_secret={{clientSecret}}&scope=analytics:metrics:read offline_access

Note: The refresh token is opaque and expires after 30 days if unused, so you must handle the rotation in your CI pipeline.

As far as I remember, the suggestion above is correct. You need offline_access to get a refresh token. I tested this in my .NET pipeline. Here is the sequence.

  1. Request initial token with offline_access.
  2. Store the refresh token securely.
  3. Use the refresh token for subsequent calls.

The .NET SDK handles this. Use PlatformClient.Auth.

var auth = PlatformClient.Auth;
var token = await auth.GetOAuth2TokenAsync("client_credentials", new Dictionary<string, string>
{
 {"client_id", clientId},
 {"client_secret", clientSecret},
 {"scope", "analytics:metrics:read offline_access"}
});

// Store token.RefreshToken

// Later, refresh
var newToken = await auth.GetOAuth2TokenAsync("refresh_token", new Dictionary<string, string>
{
 {"refresh_token", token.RefreshToken},
 {"client_id", clientId},
 {"client_secret", clientSecret}
});

This works for long builds. Do not hardcode secrets. Use GitHub Secrets. The scope must match your use case. I had issues with conversation:recording:write before. Check your scopes. The error invalid_grant usually means scope mismatch or expired refresh token. Rotate secrets if needed. This fixed my 403 errors.

Make sure you validate the scope permissions before attempting to cache the refresh token. The offline_access scope is mandatory for service accounts to receive a refresh token in the initial client_credentials grant, but many pipelines fail silently because they do not persist the token correctly or assume the token is long-lived. The refresh token itself has a limited lifetime and can be revoked if the client secret rotates.

In my setup, I use Python with requests and a Redis cache layer to handle this. I fetch the token, store it with a TTL slightly less than the expires_in value, and use the refresh token for subsequent calls within the same pipeline run. This avoids hitting rate limits and handles the 15-minute build window effectively.

Here is the JSON payload structure you must use for the initial grant. Note the inclusion of offline_access in the scope string.

{
 "grant_type": "client_credentials",
 "client_id": "YOUR_CLIENT_ID",
 "client_secret": "YOUR_CLIENT_SECRET",
 "scope": "analytics:metrics:read offline_access"
}

If you omit offline_access, the response will only contain access_token and expires_in. You will not get a refresh_token field, leading to the invalid_grant error when you try to reuse the expired token.

Once you have the refresh token, use this endpoint to get a new access token:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET

Store the refresh token in a secure secret manager, not in plain text in your CI/CD logs. If your pipeline fails, you may need to re-authenticate. I recommend wrapping this logic in a retry handler to handle transient network issues during the token refresh phase.

the suggestion above is correct about offline_access. in my react desktop builds, i often see this fail because the pipeline does not persist the refresh token correctly. service account tokens expire quickly. you must store the refresh token in a secure secret manager, not just in env vars.

here is the node.js logic i use in github actions to handle this.

  1. request initial token with offline_access.
  2. save refresh_token to github secrets.
  3. use refresh flow for subsequent steps.
const axios = require('axios');

async function getOrRefreshToken(clientId, clientSecret, refreshToken) {
 const config = {
 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
 };

 if (refreshToken) {
 const body = `grant_type=refresh_token&refresh_token=${refreshToken}`;
 return await axios.post('https://api.mypurecloud.com/oauth/token', body, config);
 }

 const body = `grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}&scope=analytics:metrics:read offline_access`;
 return await axios.post('https://api.mypurecloud.com/oauth/token', body, config);
}

do not hardcode secrets. rotate them often.