AuthonAuthon Blog
debugging6 min read

How to fix CI pipelines that break when auth providers tighten account creation

When auth providers add phone or QR verification to signup, automated account creation breaks. Here's how to redesign your pipelines to never depend on it.

AW
Alan West
Authon Team
How to fix CI pipelines that break when auth providers tighten account creation

The 3 AM Slack message every developer dreads

Last week I got pinged at 2:47 AM because our integration tests had been red for six hours. The error log was a wall of timeouts, but buried in there was the actual culprit: our provisioning script couldn't create a fresh test account on a major auth provider. The signup flow had quietly added a new verification step — one that expected a human to scan a QR code and send a text message from a real phone.

If you've automated account creation against any large provider in the last year, you've probably hit some version of this. Gmail, Microsoft, Apple, even some smaller players — they've all been tightening signup flows in ways that quietly break automation. After fixing this for the third time in three different projects, I finally stopped patching symptoms and rebuilt the whole approach.

Here's what I learned, what actually fixes it, and how to stop the bleeding for good.

The root cause: you're using human auth for machine work

The deeper problem isn't that auth providers added phone verification. It's that we built automation on top of a primitive (a human account) that was never meant to be created programmatically. Account creation is a fraud-prevention surface, and providers will keep adding friction there. Captchas. Phone verification. Device attestation. QR-code-to-SMS flows. None of those are going away — if anything, they're getting weirder.

If your test setup, CI pipeline, or onboarding flow assumes you can spin up a fresh consumer account on demand, you've built on quicksand. The fix isn't to find a clever workaround for the latest verification gate. It's to stop depending on human account creation for machine workflows.

There are three places this dependency usually hides:

  • Test fixtures that create fresh accounts per test run
  • OAuth integration tests that need a real provider account to click through consent
  • Service workflows (email sending, calendar reads, drive uploads) that piggyback on a personal account

Each has a clean solution. None of them involve a Selenium script clicking through a phone verification screen.

Step 1: Replace personal accounts with service principals

For anything machine-to-machine, you want a service account, an app credential, or an API key — not a logged-in human user. Most major providers expose this. For Google APIs specifically, the pattern looks like this:

python
# pip install google-auth google-api-python-client
from google.oauth2 import service_account
from googleapiclient.discovery import build

# Service account JSON is generated once in the cloud console
# and stored as a secret in your CI environment
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']

creds = service_account.Credentials.from_service_account_file(
    '/run/secrets/sa.json',  # mount as a secret, never commit
    scopes=SCOPES,
)

# Domain-wide delegation lets the SA act as a user in your org
# without storing that user's password anywhere
delegated = creds.with_subject('ci-bot@yourdomain.com')
service = build('drive', 'v3', credentials=delegated)

The key shift: the credential is provisioned once, by a human, through whatever phone-verification hellscape the provider requires. After that, your automation uses a cryptographic key. No more signup flows in CI.

If you're stuck on a provider without service accounts, the next-best option is a long-lived refresh token. Generate it once, store it in your secret manager, and rotate on a schedule.

Step 2: Mock the provider at the OAuth boundary

For integration tests that touch OAuth, the trick is to stop testing the provider — test your own code's handling of OAuth responses. The OAuth 2.0 spec is well-defined enough that you can stand up a fake provider locally.

I've had good luck with oauth2-mock-server for Node projects and mock-oauth2-server for JVM stacks. Both speak real OIDC. Here's the Node version:

javascript
// test/setup.js
const { OAuth2Server } = require('oauth2-mock-server');

let server;

beforeAll(async () => {
  server = new OAuth2Server();
  // Generate a signing key the server uses for ID tokens
  await server.issuer.keys.generate('RS256');
  await server.start(8080, 'localhost');

  // Point your app at the mock issuer instead of the real one
  process.env.OIDC_ISSUER = server.issuer.url;
});

afterAll(async () => {
  await server.stop();
});

// In a test, mint a token with whatever claims you need
test('admin route requires admin claim', async () => {
  const token = await server.issuer.buildToken({
    scopesOrTransform: (header, payload) => {
      payload.email = 'test@example.com';
      payload.roles = ['admin'];
    },
  });
  // ...hit your app with this token
});

This runs in CI with zero network dependencies. No phone. No QR code. No flaky provider downtime taking out your test suite.

Step 3: For end-to-end flows, use seeded long-lived accounts

Sometimes you genuinely need a real provider account — for example, when validating that your app's consent screen renders correctly against the live OAuth flow. The answer here is to maintain a small pool of pre-provisioned accounts, not to create them on the fly.

My current setup:

  • Three or four accounts per provider, created manually with real phone numbers I own
  • Credentials stored in a secret manager (Vault, AWS Secrets Manager, doesn't really matter)
  • A CI step that picks an account from the pool and releases it on test completion
  • A nightly job that logs in to each account to keep them warm and catch expired sessions
bash
# Pseudo-pseudocode for picking an account from a Vault pool
ACCOUNT=$(vault kv get -format=json secret/test-accounts/google \
  | jq -r '.data.data | to_entries | .[0]')

EMAIL=$(echo "$ACCOUNT" | jq -r '.key')
PASSWORD=$(echo "$ACCOUNT" | jq -r '.value')

# Run E2E against the seeded account
TEST_EMAIL="$EMAIL" TEST_PASSWORD="$PASSWORD" npx playwright test

This is unglamorous but reliable. The accounts are stable, your test runs are deterministic, and when a provider rolls out a new signup gate, your CI doesn't even notice.

Prevention: how to stop hitting this wall

A few habits that have saved me real pain:

  • Treat account creation as a one-time, human-driven event. If your runbook says "the CI bot creates an account," rewrite it.
  • Audit your tests for hidden signup flows. Grep for signup, register, createAccount — anything that touches a real provider's registration endpoint is a future outage.
  • Document which credentials are service accounts vs. seeded human accounts. When something breaks, the difference matters.
  • Subscribe to provider changelogs. Most major auth providers post breaking-change notices weeks ahead. The SMS-sending verification flow that broke my pipeline last week was actually announced in a community thread first.
  • Have a manual fallback for emergency provisioning. Sometimes you really do need a new account at 2 AM. Knowing which engineer has a spare phone number speeds things up.

The broader lesson: every time an auth provider adds friction at signup, they're telling you something. They don't want bots creating accounts. Your automation is a bot, even if it has good intentions. The sustainable move is to design around that constraint, not against it.

How to fix CI pipelines that break when auth providers tighten account creation | Authon Blog