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:
base_urltoken_urlclient_idclient_secret(better: Postman Vault, see below)
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:
- It travels with the collection, not with each environment.
- Newman does not write collection variables back to your environment file.
- Swapping
dev/staging/prodenvironments doesn’t leave a token from one stage sitting in another stage’s file.
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:
- Postman waits for pending
pm.sendRequestcallbacks in a pre-request script before sending the main request, so the token is set in time. (Postman sandbox reference: pm.sendRequest) - If your flow is
authorization_code, you can’t bootstrap from a script (it needs a browser redirect). Get the first token through the Authorization tab, copy the refresh token into therefresh_tokencollection variable once, and the script handles rotation from there. expires_inis optional per spec. The fallback of 3600 is an assumption; adjust it to your provider. (RFC 6749 §5.1)
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
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
- No
access_token/refresh_tokenin any environment - Tokens set with
pm.collectionVariables.set()only - Collection auth = Bearer
{{access_token}}, requests inherit - Refresh with a 60s skew, overwrite the refresh token when a new one comes back
- No
--export-environmentpointing at a tracked file - Client secret in Vault, not in an initial value
References
- Postman — Variables and scopes: https://learning.postman.com/docs/sending-requests/variables/variables/
- Postman — Sandbox reference (
pm.sendRequest,pm.collectionVariables): https://learning.postman.com/docs/tests-and-scripts/write-scripts/postman-sandbox-reference/ - Postman — OAuth 2.0 authorization: https://learning.postman.com/docs/sending-requests/authorization/oauth-20/
- Postman — Vault: https://learning.postman.com/docs/sending-requests/postman-vault/postman-vault-secrets/
- Newman CLI options: https://learning.postman.com/docs/collections/using-newman-cli/newman-options/
- RFC 6749 — OAuth 2.0 (§5.1 token response, §6 refresh): https://datatracker.ietf.org/doc/html/rfc6749
- RFC 9700 — OAuth 2.0 Security Best Current Practice: https://datatracker.ietf.org/doc/html/rfc9700