Skip to content
>GLB_
Go back

Managing OAuth2 Token Rotation in Postman Without Overwriting Environments

The usual Postman OAuth2 setup looks harmless: a test script grabs access_token from the token response and calls pm.environment.set(). Then you share the environment, run it in CI with Newman, or click “Persist all”, and now your environment file holds a stale token, a rotated refresh token that no longer works, and a diff nobody asked for.

The fix is not a smarter script. It’s putting tokens in a different scope than configuration.

The rule: environments hold config, not state

An environment should describe where and as whom you talk to an API:

Tokens are runtime state. They expire, and with refresh token rotation they are single-use. They don’t belong in a file you export or sync.

Postman variables already have two values per variable: the initial value (synced/shared) and the current value (local). pm.*.set() writes the current value. The overwrite problem appears when that current value is persisted or exported. (Postman docs: variables)

Where to store tokens instead

Use collection variables (pm.collectionVariables) for access_token, refresh_token and access_token_expires_at.

Why collection scope:

One catch: scope precedence. Postman resolves {{access_token}} from the narrowest scope that defines it: global → collection → environment → data → local. If your environment also has an access_token variable, it wins and silently shadows the fresh collection value. Delete the token variables from every environment. (Postman docs: variable scopes)

Collection-level pre-request script

Put this in Collection → Scripts → Pre-request. It runs before every request in the collection, refreshes only when needed, and never touches the environment.

// >GLB_ // OAuth2 token rotation, collection scope only
const SKEW_MS = 60 * 1000; // refresh 60s before expiry

const tokenUrl     = pm.environment.get('token_url');
const clientId     = pm.environment.get('client_id');
const clientSecret = pm.environment.get('client_secret');

const accessToken  = pm.collectionVariables.get('access_token');
const refreshToken = pm.collectionVariables.get('refresh_token');
const expiresAt    = Number(pm.collectionVariables.get('access_token_expires_at') || 0);

const stillValid = accessToken && Date.now() < expiresAt - SKEW_MS;

if (!stillValid) {
  const form = refreshToken
    ? [
        { key: 'grant_type', value: 'refresh_token' },
        { key: 'refresh_token', value: refreshToken },
      ]
    : [{ key: 'grant_type', value: 'client_credentials' }];

  form.push(
    { key: 'client_id', value: clientId },
    { key: 'client_secret', value: clientSecret },
  );

  pm.sendRequest(
    {
      url: tokenUrl,
      method: 'POST',
      header: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: { mode: 'urlencoded', urlencoded: form },
    },
    (err, res) => {
      if (err) {
        console.error('Token request failed:', err);
        return;
      }
      if (res.code !== 200) {
        // Refresh token expired or already rotated: drop it, next request falls back
        pm.collectionVariables.unset('refresh_token');
        pm.collectionVariables.unset('access_token');
        console.error('Token endpoint returned', res.code, res.text());
        return;
      }

      const body = res.json();
      pm.collectionVariables.set('access_token', body.access_token);
      pm.collectionVariables.set(
        'access_token_expires_at',
        String(Date.now() + Number(body.expires_in || 3600) * 1000),
      );

      // Rotation: the server MAY return a new refresh token. If it does, the old one is dead.
      if (body.refresh_token) {
        pm.collectionVariables.set('refresh_token', body.refresh_token);
      }
    },
  );
}

Then set the collection Authorization to Bearer Token with {{access_token}}, and leave every request on “Inherit auth from parent”.

Notes on the script:

Why rotation breaks “shared” tokens

With refresh token rotation, every refresh returns a new refresh token and invalidates the previous one. The OAuth 2.0 Security BCP recommends exactly this for public clients. (RFC 6749 §6, RFC 9700 §4.14)

That means if two people (or a person and a CI job) share one refresh token through a synced environment, the first one to refresh wins and the second one gets invalid_grant. Per-collection, local-only state avoids that race. Shared refresh tokens are the bug, not the script.

Newman: don’t export what you don’t need

Newman never modifies your environment file unless you ask it to:

# Safe: env.json is read-only input
newman run collection.json -e env.json

# This writes current values (tokens included, if they live in the env) back to disk
newman run collection.json -e env.json --export-environment env.json

If you need state between runs in CI, export to a separate artifact instead of the source file, and keep it out of git:

newman run collection.json -e env.json --export-collection /tmp/run-state.json

(Newman options)

Secrets: Postman Vault

client_secret in an environment’s initial value gets synced to your workspace. Postman Vault stores secrets locally and references them as {{vault:client_secret}}. In scripts, vault access is async (await pm.vault.get('client_secret')) and must be enabled per script in the vault settings. (Postman Vault)

Alternative: the built-in OAuth 2.0 helper

The Authorization tab’s OAuth 2.0 type can request tokens and auto-refresh them, storing tokens in Postman’s token manager rather than in variables. It’s good for interactive use. I still prefer the script for collections that run in Newman/CI, because the helper’s interactive grant flows don’t run headless and the script makes the refresh logic explicit and versioned. (Postman docs: OAuth 2.0)

Checklist

References


Share this post:

Related Posts


Previous Post
S3 Eventual Consistency Edge Cases in Data Pipelines
Next Post
Designing Event Schemas for Product Analytics in ClickHouse