OAuth and SSO add two complications to login that vanilla email-password never has: the user is bounced to a different origin (Google, Microsoft, Okta, Auth0), and the auth provider's UI is something the test team doesn't control. Type into Google's email field and one rate-limit-or-captcha later your CI suite is failing on a security check rather than your own product. This lesson covers the three workable strategies — API bypass, cy.origin-driven UI flow, and stub-the-redirect — and the rule of thumb for picking between them.
The three strategies
The shape: pick one of strategy 2 or 3 for coverage of the auth redirect; use strategy 1 for everything else. Most teams end up with one OAuth UI test and dozens of OAuth-bypassed tests.
Strategy 1 — API bypass via the token endpoint
OAuth providers expose a token endpoint that accepts username/password (or a service-account assertion) and returns an access token. Hitting it from cy.request skips the entire UI:
declare global {
namespace Cypress {
interface Chainable {
loginViaOAuth(): Chainable<void>;
}
}
}
Cypress.Commands.add("loginViaOAuth", () => {
cy.request({
method: "POST",
url: "https://auth.provider.com/oauth/token",
body: {
grant_type: "password",
username: Cypress.env("OAUTH_USERNAME"),
password: Cypress.env("OAUTH_PASSWORD"),
client_id: Cypress.env("OAUTH_CLIENT_ID"),
client_secret: Cypress.env("OAUTH_CLIENT_SECRET"),
audience: Cypress.env("OAUTH_AUDIENCE"),
},
}).then((response) => {
window.localStorage.setItem("access_token", response.body.access_token);
if (response.body.id_token) {
window.localStorage.setItem("id_token", response.body.id_token);
}
});
});
export {};beforeEach(() => {
cy.loginViaOAuth();
cy.visit("/dashboard");
});Speed: a few hundred milliseconds. Reliability: very high — no DOM scraping, no captcha. Cost: you need the IdP to expose the resource-owner-password grant type (Auth0, Okta, Cognito all do; some configurations disable it for security). If yours doesn't, ask the auth team for a test tenant that allows it.
The token goes into wherever your app actually reads it — localStorage, an httpOnly cookie set by your backend's callback, or both. Inspect the real OAuth callback to see what it stores, then replicate that exact step.
Wrap cy.loginViaOAuth in cy.session (previous lesson) for the usual cache + validate combo and you have an OAuth setup that's as fast as plain API login.
Strategy 2 — cy.origin for real UI coverage
Sometimes you genuinely need to test the IdP redirect — the team that owns the OAuth integration wants to know if the consent screen broke after a config change. Cypress 9.6+ supports cross-origin testing via cy.origin:
it("logs in via the real Google OAuth flow", () => {
cy.visit("/login");
cy.get("[data-testid='login-with-google']").click();
cy.origin("https://accounts.google.com", () => {
cy.get("input[type='email']").type(Cypress.env("GOOGLE_TEST_EMAIL"));
cy.get("#identifierNext").click();
cy.get("input[type='password']", { timeout: 15_000 }).type(
Cypress.env("GOOGLE_TEST_PASSWORD"),
{ log: false },
);
cy.get("#passwordNext").click();
});
cy.url().should("include", "/dashboard");
cy.contains("Welcome").should("be.visible");
});Inside the cy.origin callback, you're effectively running a sub-test in the foreign origin. Selectors target the IdP's DOM; outside the callback you're back on your own origin asserting the post-redirect state.
Three caveats that bite every team that adopts this:
- Use a dedicated test account with no MFA, no second-factor prompt, no captcha trigger. Real engineering accounts with MFA fail in random ways.
- Don't run it on every push. A few times a day is enough — once per merge to main, plus a smoke run before each release.
{ log: false }on the password line keeps the secret out of the Cypress command log and the artifact videos.
This is the test that costs ten seconds and runs nightly, not the one in beforeEach of every spec.
Strategy 3 — stubbing the auth redirect
For frontend-only test suites — component-test-style coverage of how the UI handles authenticated state — neither real auth nor an IdP detour is necessary. Stub the network calls the OAuth callback would have made:
beforeEach(() => {
cy.intercept("POST", "/api/auth/callback", {
statusCode: 200,
body: {
access_token: "test-token-abc123",
id_token: "test-id-token-xyz",
user: {
id: 42,
email: "alice@test.com",
name: "Alice Reed",
},
},
}).as("authCallback");
cy.intercept("GET", "/api/users/me", {
fixture: "users/alice.json",
});
cy.visit("/dashboard?code=fake-oauth-code");
cy.wait("@authCallback");
});The app calls its own backend's OAuth callback endpoint (which would normally exchange the code with the IdP); your intercept short-circuits that call and returns a fake token + user. The app continues exactly as if a real OAuth flow had succeeded.
This is appropriate for tests that don't need to verify the auth roundtrip — you're testing the dashboard, not the login. It's the fastest of the three strategies and has zero IdP dependency.
The downside: if your backend's OAuth callback breaks, this test won't catch it. Pair it with one strategy-2 test that does drive the real flow.
A complete typed setup
A combined login-router that picks the right strategy per spec:
declare global {
namespace Cypress {
interface Chainable {
loginOAuth(mode?: "api" | "stub"): Chainable<void>;
}
}
}
Cypress.Commands.add("loginOAuth", (mode = "api") => {
if (mode === "api") {
cy.session("oauth-api", () => {
cy.request({
method: "POST",
url: Cypress.env("OAUTH_TOKEN_URL"),
body: {
grant_type: "password",
username: Cypress.env("OAUTH_USERNAME"),
password: Cypress.env("OAUTH_PASSWORD"),
client_id: Cypress.env("OAUTH_CLIENT_ID"),
},
})
.its("body.access_token")
.then((token) => window.localStorage.setItem("access_token", token));
});
} else {
cy.intercept("POST", "/api/auth/callback", {
statusCode: 200,
body: { access_token: "stub-token", user: { id: 42, email: "alice@test.com" } },
}).as("authCallback");
}
});
export {};Tests pick their flavour:
beforeEach(() => {
cy.loginOAuth("api"); // most specs
cy.visit("/dashboard");
});
// One test, somewhere in the suite:
it("drives the real Google OAuth UI", () => {
// strategy 2 with cy.origin — only here
});Credential hygiene
A short and non-negotiable list:
- Never commit OAuth credentials to git.
cypress.env.jsonis gitignored; production CI uses secret stores. - Use a dedicated test tenant with throwaway accounts. Real engineer accounts fail when MFA, captcha, or company SSO policies kick in.
- Use the resource-owner-password grant only on test tenants. Production OAuth configurations should disable it; that's why you need the test tenant.
- Pass the password with
log: falseon every type call:cy.get(...).type(password, { log: false }). The Cypress runner records command arguments otherwise and they end up in CI artifacts.
Chapter 5's lesson on environment variables covers the file/CLI/CI plumbing in detail.
⚠️ Common mistakes
- Trying to test Google's real login UI on every CI push. Google detects automation, rate-limits the test account, and triggers captcha when traffic spikes. Strategy 2 should run a few times a day, not a few times per push. For per-push coverage, use strategy 1 or 3.
- Plant-the-token bypass that doesn't match how the real callback stores credentials. OAuth callbacks often set both
localStorageand anhttpOnlycookie via the backend. Planting just thelocalStoragevalue silently logs the user out the moment any API call requires the cookie. Inspect the real flow before replicating. - Logging the password through
cy.type(password)without{ log: false }. Cypress's command log records the argument, the headless run records a video, and the CI artifact upload publishes both. Add{ log: false }reflexively for any sensitive value.
🎯 Practice task
Pick the right strategy for a real OAuth-protected app. 30-40 minutes (longer if setting up a test tenant from scratch).
- API bypass setup — create a free Auth0 tenant or use whatever your team already has. Add a single test user. In
cypress.env.json(gitignored), put the credentials and client ID. Implementcy.loginOAuth("api")against your tenant's/oauth/tokenendpoint following the lesson's pattern. - Wrap the API bypass in
cy.sessionwith avalidatehook that hits/userinfo(Auth0) or/me(your app). Confirm cached restores happen on subsequent tests. - One real-UI test with
cy.origin— pick a single spec, log in by clicking "Continue with Google" (or Auth0's hosted page), drive the email + password fields insidecy.origin. Run it a few times. Note any flakiness — that's why you reserve this strategy for one or two specs. - Stub strategy — pick a frontend-only test (e.g., dashboard rendering with a known user). Stub
/api/auth/callbackand/api/users/mewith fixtures. Confirm the test runs without ever talking to the IdP. - Compare timings — same dashboard test, three strategies. The API bypass should run in ~500 ms, the stub in ~200 ms, the cy.origin flow in ~10 seconds. Confirm the order of magnitude.
- Stretch: wire
cy.loginOAuth("api")into abeforeof an admin-panel spec. Add avalidatehook that re-runs the OAuth flow if the token has expired. Force expiration by sleeping the test for longer than the token's lifetime — confirmvalidatecatches it and refreshes silently.
The last lesson of chapter 6 tackles the cousin of authentication — multi-step forms and wizards, where each step has its own state, validation, and back/forward semantics.