Skip to main content

API testing

Testing API Auth Without Real Credentials

How to reproduce scope denials, token expiry and refresh-token rotation in a sandbox — using the auth contract your OpenAPI spec already declares.

August 16, 2026FetchSandbox Engineering

A developer asked us this last week, and it is the question that stalls most integration testing:

“How do you handle auth for the sandboxed calls? That is where we keep stalling.”

Short answer

Use synthetic credentials against a service twin that enforces the provider's real security contract. Test 401 expiry separately from 403 scope denial, assert the exact retry or stop behavior, and keep production tokens and OAuth client secrets out of development and CI.

It stalls because both obvious answers are bad. Put real credentials in CI and you have a secret in a place secrets should not be, plus tests that write to a live account. Skip auth entirely and your sandbox accepts anything — which means every auth bug in your integration survives testing and ships.

There is a third option, and most people miss it because the information is already sitting in a file they own.

Your spec already declares the auth contract

OpenAPI has securitySchemes and a per-operation security block. Providers fill them in properly. HubSpot's Contacts API, for example, declares four schemes side by side — a deprecated API key in the query string, OAuth 2.0, and two private-app token styles — and then declares the required scope on every single operation:

GET   /crm/v3/objects/contacts   oauth2 ['crm.objects.contacts.read']
POST  /crm/v3/objects/contacts   oauth2 ['crm.objects.contacts.write']
PATCH /crm/v3/objects/contacts/{id}  oauth2 ['crm.objects.contacts.write']

That is machine-readable. A sandbox can read it and enforce exactly what the real API enforces, with a synthetic token, without a single real credential. Read operations pass, write operations fail, and they fail the way the provider fails.

Most mock servers do not do this. They check that a token is present and move on. That is the difference between a mock that accepts your call and a sandbox that reproduces your bug.

Three auth failures worth reproducing

These are not hypotheticals. They came out of clustering 582 real developer failure reports about one API, and each one has the same shape: the integration looks healthy, and the failure is silent.

1. The token can read but not write

An app is granted read scopes and never write. Reads succeed, so monitoring is green and the integration looks fine. Writes return 403, the handler treats it like any other error, and the data never lands.

TODAY (no sandbox)
  agent writes → real API → 403 → caught by a generic error handler
                                → retried forever against a permission
                                  that will never change
                                → CRM silently never updates

WITH A SANDBOX THAT ENFORCES SCOPES
  agent writes → sandbox → 403, same shape as the provider
       agent sees a terminal auth failure, not a transient one
       fix: surface it, stop retrying, ask for the scope
       re-run the same scenario on the patched code
       before: silent no-op · after: loud, actionable failure

One detail matters here. When we ran a genuinely read-only token against HubSpot, the 403 body was not a bare error. It named the scopes required:

{
  "status": "error",
  "category": "MISSING_SCOPES",
  "errors": [{ "context": { "requiredGranularScopes": [
      "crm.objects.contacts.write", "crm.schemas.contacts.write" ] } }]
}

A handler can act on that. A handler that collapses it into “request failed” cannot. If your sandbox returns a simpler shape than the provider, you cannot test the code that reads it.

2. The token expires halfway through a batch

Long-running syncs cross expiry boundaries. The first few hundred records succeed, then everything 401s. Whether that is recoverable depends entirely on code nobody tested, because expiry never happens during a five-minute test run.

TODAY
  batch runs → token expires at record 400 → rest 401
             → job reports "completed with errors"
             → 600 records silently missing

WITH A SANDBOX THAT CAN EXPIRE A TOKEN ON DEMAND
  batch runs → sandbox expires the credential after N requests
       agent sees the boundary, adds refresh + resume
       assert processed_count == submitted_count
       before: 400 of 1000 · after: 1000 of 1000

3. The refresh itself kills the integration

This is the nastiest of the three. Refresh tokens rotate: RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated, meaning each refresh invalidates the previous token. Fire two concurrent refreshes for one credential and the second one presents a token that has already been used — and the provider revokes the whole grant.

TODAY
  two workers hit expiry at once → both refresh
             → second uses a rotated token
             → grant REVOKED
             → customer has to re-authorise by hand

WITH A SANDBOX THAT ROTATES
  same race → sandbox revokes exactly like the provider
       fix: serialise refresh per credential
       assert refresh_count == 1, and the grant still works after
       before: dead integration · after: one refresh, run completes

Note the assertion is == 1, not “at least one.” A handler that refreshes on every request never sees an expiry error and looks fixed. It is not fixed. It is a rate-limit problem waiting to happen, and the exact-count assertion is what catches it.

What this needs from a sandbox

Four things, and none of them require your production credentials:

  • Read securitySchemes and per-operation security from the spec, and enforce them — including API keys in headers, query strings and cookies, not just bearer tokens.
  • Treat security as a list of alternatives. Stripe declares basic OR bearer on all 587 of its endpoints. Getting that backwards breaks everything.
  • Model the credential, not just the header: granted scopes, a request budget before expiry, a refresh token that rotates and can be revoked.
  • Return the provider's actual error shape, not a generic one.

The part people skip

It is not enough to make the failure happen. You have to prove the fix worked — and the most common wrong fix makes the symptom disappear without fixing anything.

Catch the 403 and return success: no more errors, no more data either. Refresh before every request: no more expiry errors, and a new rate-limit problem. Halt the batch on the first 401: no more crashes, and 600 missing records.

Every one of those passes a test that only asks “did the error stop?” The assertion has to be the exact end state a correct implementation leaves — processed_count == submitted_count, refresh_count == 1, the record unchanged after a denied write. Assert equality, not absence.

That is the whole idea behind how we build sandboxes at FetchSandbox: reproduce the real failure against your real code, then prove the fix flipped it. If we cannot reproduce the bug, we say so instead of showing you a green.

Questions people ask

Can I test API authentication without a real access token?

Yes. Use synthetic credentials against a service twin that enforces the provider's operation-level security contract and error shape. Keep real credentials for final provider connectivity testing.

Should an API client retry a 403 missing-scope error?

No. A valid credential without the required scope will not gain permission through backoff. Stop, surface the exact missing scope, and request reauthorization.

What is the difference between an API 401 and 403?

A 401 usually means the credential is missing, invalid, revoked, or expired. A 403 can mean the credential is valid but lacks permission for that operation. Parse the provider response before choosing refresh, reconnect, or stop.

Does an API service twin replace provider sandbox testing?

No. A service twin makes negative auth states deterministic during development and CI. Use the provider sandbox afterward to validate real OAuth redirects, consent, credentials, and account connectivity.