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:
# 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:
// 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
# 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 testThis 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.
