ego (lite) はブラウザそのもの、ego は端末を横断して動くあなた専属のエージェントです。
順番待ちリストに参加する
Playwright認証storageStateブラウザ自動化ログイン済みセッション

Playwright認証:一度ログインし、セッションを再利用し、期限切れになったら再認証する

2026年9月16日14 分で読了
ピクセルアートの Playwright と ego (lite) がノートPCのサインイン画面の横に立ち、Agent is in control が見える

Playwrightのテストやスクレイパーが認証を必要とするとき、毎回ゼロからログインする理由はほとんどありません。よくあるやり方は、一度ログインし、Cookieと関連するブラウザ状態をstorageStateで保存し、後続の実行でその状態を読み込んで同じセッションを再利用することです。ただし保存済みのstorageStateファイルがあるからといって、ログインがいつまでも効くわけではありません。セッションは期限切れになることも、サーバー側で失効することもありますし、最初から正しく読み込まれないこともあります。

自分のアカウントを自動化していて、日常使いのブラウザですでにサインイン済みなら、別の選択肢があります。その既存のブラウザセッションを直接再利用することです。ego (lite) は、必要なログイン状態をすでに持っているブラウザでAIエージェントを動かします。各タスクはそれぞれのSpaceに隔離されます。タスクが確認コード、QRログイン、その他あなたが必要な手順に達したら、MFAを自動化しようとするのではなく、見えるブラウザで引き継げます。

どちらの方法でも目標は同じです。自動化が本当に有効な認証済みセッションを持っていることを確認することです。突然ログインページに戻される実行、401を返すAPI、現れないログイン後の要素は、ナビゲーション、権限、セレクタの問題という3つの別問題に見えますが、いずれも同じ壊れたセッションを指していることがあります。以降の節では、再現可能なローカル環境で、Playwrightの認証状態を保存、再利用、確認、更新する方法を示します。

壊れたPlaywrightログインの見え方

フィクスチャ実行では、失効した1つのセッションが3つの症状を同時に出しました。サーバー側セッションを消したあと、保存済み状態を再利用するcontextはダッシュボードへ戻るnextパラメータ付きでログインページへリダイレクトされ、認証済みJSONエンドポイントへのプローブは401を返し、ダッシュボードの表を待つ処理は設定どおり3秒でタイムアウトしました。

公開の the-internet ログインでも、同じ種類の失敗は bounce です。生きたセッションなしで /secure を開くと /login に落ち、赤いフラッシュ You must login to view the secure area が出ます。下のコールアウトの URL 確認がこれです。ブラウザは locator が期待したページではなくログインフォームにいます。このスクリーンショットがその bounce です。Logout が storageState ファイルを消した証明ではありません。the-internet の Logout は現在のウィンドウセッションを終えます。ディスクに既に書いたファイルは、Cookie 自体が死ぬまで /secure を開き直せます。

保護領域へのリクエスト後の the-internet.herokuapp.com/login。赤いフラッシュ You must login to view the secure area
生きたセッションなしで保護ページを開くと /login に bounce しました。赤いフラッシュが手がかりです。欠けた、または死んだセッションであり、壊れた locator でも、Logout が保存済み storageState ファイルを消したのでもありません。

3つ目の症状がいちばん高くつきます。ログイン後の要素でlocatorがタイムアウトしても、locatorが間違っているとは限りません。見ているページが想定したページではない、という意味です。上の実行では表が描画されませんでした。ブラウザがログインフォームに止まっていたからです。それをセレクタ問題として扱うと、壊れていなかったクエリを直すことになります。

認証失敗の4つの根本原因

認証失敗は4つの原因に集まり、直し方もそれぞれ違います。直す前に分類すれば、修正がまたwait文を1行足すだけ、にはなりません。

1. 状態が一度も読み込まれていない

storageStateなしで作ったcontextは、前回の実行がどうであれ、最初からサインアウトです。保存タイミングを間違える、相対パスが別の場所に解決される、状態が依存プロジェクトへ渡らないprojectでsetupを実行する、でも同じ結果になります。手がかりは、まっさらなcontextが失敗側と同じ動きをすることです。どちらもサインアウトです。

2. Cookieはあるが死んでいる

セッションCookieは存在し、正しいドメインとフラグを持っていても、サーバーに拒否されることがあります。セッションCookieには独自の寿命があり、サーバーは再デプロイ、パスワード変更、アイドルタイムアウトなど、いつでも失効できます。Cookieのexpiresフィールドはヒントであり保証ではありません。サーバー側セッションはそれより先に死にます。フィクスチャが明示的なrevoke呼び出しで再現するのは、このケースです。

3. context間の隔離

CookieとlocalStorageはブラウザではなく、ブラウザcontextに属します。あるcontextから状態を保存し、設定の違うcontextがそれを共有すると期待すると、静かに失敗します。並列workerも同じです。workerごとに独自のcontextを持つので、各自の状態ファイルか各自のログイン手順が必要です。この隔離は機能です。ある場所では効くセッションが別の場所では消えたように見える理由でもあります。

4. SSOのリダイレクト連鎖

シングルサインオンでは、入りたいアプリがセッションを持っているオリジンであることはほとんどありません。ログインは別ドメインのIDプロバイダへリダイレクトし、プロバイダが独自のCookieをセットし、codeまたはTokenとともに制御がアプリへ戻ります。連鎖が終わる前に状態を保存すると、セッションの一部だけを掴んでIdPのCookieを落とし、次の実行で同じリダイレクトが再現されます。信頼できる型は、最終的なアプリURLと認証済みマーカーを待ってから保存し、連鎖に関わるすべてのオリジンを状態ファイルへ乗せる方法です。

一度ログインし、状態を正しく保存する

一度ログインする流れは短いです。遷移し、認証し、認証済み状態を確認してから、storageStateをファイルへ書きます。確認ステップこそ、安定して効く状態ファイルと、たまに効かない状態ファイルを分けます。

import { chromium } from "playwright";

const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

await page.goto("https://app.example.com/login");
await page.fill("input[name=email]", process.env.APP_EMAIL);
await page.fill("input[name=password]", process.env.APP_PASSWORD);
await page.click("button[type=submit]");

// Wait for the authenticated destination, not just for the click to land.
await page.waitForURL("**/dashboard");

// Verify before saving: an authenticated element plus a 200 from the API.
await page.waitForSelector("#revenue-table");
const probe = await context.request.get("https://app.example.com/api/summary");
if (probe.status() !== 200) throw new Error("login did not take");

await context.storageState({ path: "playwright/.auth/user.json" });
await browser.close();

フィクスチャ実行では、ログインページを開いてから状態ファイルを書くまで119ミリ秒でした。ファイルにはCookieが1つ、サーバーが発行したセッション識別子が入っており、プローブは200を返しました。この種の数字はフィクスチャ固有ですが、移せるのは検査の形です。遷移先URLを確認し、見える認証済み要素を確認し、認証済みエンドポイントを確認し、それから初めて永続化します。

下の headed 実行はフィクスチャのダッシュボードではなく the-internet.herokuapp.com です。119 ミリ秒はフィクスチャ側の数字です。スクリーンショットは公開ログインでも同じ保存の形を示します。認証後の /secure、表示中の Logout、/tmp/pw-auth.json を確認する Claude です。

ログイン後の the-internet.herokuapp.com/secure を開いた headed Chromium の横に Claude Code。Logout が見え、Claude が /tmp/pw-auth.json を確認している
ログイン後、headed Chromium は /secure に留まりました。Claude は /tmp/pw-auth.json を確認していました。後の context が再利用するのはこのファイルです。

テストスイートでは、同じ考えはファイルを作るsetup projectと、それを消費する依存projectとして現れます。これは公式の認証ガイドが文書化しているパターンです。スクリプトやスクレイパーでは、上の手順が流れの全部です。どちらにせよ、このファイルは資格情報です。生きたセッションCookieが入っているので、パスワードと同じ扱いをし、バージョン管理には入れません。

テストとスクレイパーで保存済み状態を再利用する

再利用は、context作成時の1行の変更です。スクリプトではファイルをcontextへ渡します。Playwrightのテストスイートでは、projectごと、またはテストファイルごとに設定し、すべてのworkerが同じサインイン済み状態から始まるようにします。

// Script: create the context from the saved state.
const context = await browser.newContext({
  storageState: "playwright/.auth/user.json",
});

// Test: use the state for this file (or configure it per project).
test.use({ storageState: "playwright/.auth/user.json" });

フィクスチャ実行では、保存済みファイルから作った新しいcontextが、ログイン手順なしで6ミリ秒で保護されたダッシュボードへ到達し、認証済みプローブは期待どおりのペイロード付きで200を返しました。面白い数字は6ミリ秒ではありません。流れで変わったのは、contextの状態の出所だけだということです。

再利用のスクリーンショットも同じ公開サイトです。新しい headed context が /tmp/pw-auth.json を読み込み、ログインフォームを飛ばし、Logout が既にある /secure を開きました。6 ミリ秒は引き続きフィクスチャの計測です。

Claude Codeが/tmp/pw-auth.jsonから新しいPlaywright contextを作成し、ログインフォームは埋めていない。横ではthe-internet.herokuapp.com/secureにLogoutがまだ見える
新しい headed context が storageState を読み込み、直接 /secure を開きました。プロンプトはログインフォームを埋めないこと。Logout は既にページ上にありました。

状態ファイルが何を運び、何を運ばないかを知っておく価値があります。contextが触ったすべてのオリジンのCookieと、オリジンごとのlocalStorage項目は入ります。IndexedDB、session storage、service workerの状態は入りません。TokenをIndexedDBに置くアプリは、追加の取得手順か再ログインが必要です。storageStateがそれをカバーすると期待するのは、幽霊のような失敗のよくある原因です。Cookie自体はドメイン、パス、フラグに従い、説明はMDNのCookieガイドSet-Cookieのリファレンスにあります。

期限切れまたは失効したセッションの検出

いちばん安い検出器は、コストの高い作業の前に送る認証済みリクエストです。多くのアプリには、現在のユーザー、件数、設定オブジェクトを返す小さなエンドポイントがあり、そのステータスコードがセッションのステータスコードです。リクエストはミリ秒で済みます。スクレイプの途中で失敗に気づくと、実行全体が無駄になります。

async function sessionIsAlive(context) {
  const probe = await context.request.get(
    "https://app.example.com/api/summary",
    { failOnStatusCode: false },
  );
  return probe.status() === 200;
}

if (!(await sessionIsAlive(context))) {
  await loginAndSaveState(context);
}

Cookieのexpiresフィールドだけに頼ってはいけません。それはブラウザがCookieの送信を止めるべき時点であり、サーバーが受け取りを止める時点ではありません。サーバー側の失効は、尋ねるまで見えません。フィクスチャのrevoke呼び出しはまさにその状況です。Cookieはまだあり、形も正しく、サーバーは401を返しました。

自動で再認証する

自動再認証は小さな状態機械です。検出し、再ログインし、保存済み状態を更新し、失敗した手順を一度だけ再実行し、確認します。上限が重要です。ループの中で黙って再認証し続けるワークフローは、誤ったパスワード、アカウントロック、変わったログインページを隠し、そのあいだ喜んでトラフィックを増やします。

async function withSession(page, context, task) {
  if (!(await sessionIsAlive(context))) {
    await loginAndSaveState(context, page); // refresh file after login
  }
  try {
    return await task();
  } catch (error) {
    if (!(await sessionIsAlive(context))) {
      await loginAndSaveState(context, page); // one retry, then fail loudly
      return await task();
    }
    throw error;
  }
}

フィクスチャでは検出器が401を見て、再ログインと確認が99ミリ秒で終わり、更新後の状態ファイルは再びセッションCookieをちょうど1つ持っていました。実行は保護されたダッシュボードで終わり、表が見え、3行が描画されていました。この3つの確認なしで終わる再認証は、完了していません。

複数のworkerが1つの状態ファイルを共有すると、全員が同じ瞬間に期限切れに気づき、再ログインを競います。対策はsingle-flightの調整です。最初のworkerがファイルを更新し、他は待つか、各workerが自分のコピーを更新します。仕組みは、複数のエージェントが同じブラウザプロファイルに対して動くときに出るセッション永続化の問題と同じです。

複数アカウントと並列実行

隔離はcontext単位なので、ルールはアカウントまたはロールごとに状態ファイル1つ、workerごとにcontext 1つ、共有の可変セッションなしです。フィクスチャでは、2つの並列ログインが異なるセッションCookieを2つ作り、両方のプローブが同じミリ秒で200を返し、どちらのcontextも相手のセッションを見えませんでした。壊れたときの失敗は微妙なので、この性質は意図して守ります。2つのアカウントが互いの状態を上書きし、テストが間違ったユーザーで通ってしまいます。

同じ規則は実ブラウザでも現れます。2 つの Space は 2 つの context です。一方は新しいタブで idle、他方はサインイン済みの Airbnb セッションを保てます。1 つの headed ウィンドウのように Cookie を共有しません。要点は隔離です。概要は両方を同時に見る手段にすぎません。

idle の Google Space と実行中のサインイン済み Airbnb Space を示す ego (lite) Spaces 概要の横に Claude Code
実行中のサインイン済み Airbnb Space の横に idle の Google Space。隔離は見えます。idle タブは 2 つ目のサインイン検索ではありません。

認証が本当に効いたかを確認する

確認は3つの検査です。どれかを飛ばすと、ワークフローは静かに壊れたままになります。1つ目は肯定です。ログイン後にしか存在しない要素があること。2つ目は否定です。ログインフォームがないこと。両方を含むページは通しません。3つ目はデータ検査で、古いページやキャッシュされたページでは失敗します。実行ごとに変わらなければならない値などです。

await page.waitForSelector("#revenue-table");      // positive
await expect(page.locator("#login-form")).toHaveCount(0); // negative
const probe = await context.request.get("/api/summary"); // data
assert.equal(probe.status(), 200);
assert.ok((await probe.json()).q3Revenue);

フィクスチャの再利用実行では3つとも通りました。表は見え、ログインフォームの件数はゼロ、JSONペイロードには期待したフィールドがありました。3つ合わせても数ミリ秒で、効いているセッションと壊れたセッションの差を推測から事実に変えます。まだおかしい実行向けのデバッグ手法はPlaywrightデバッグガイドにあり、ベストプラクティスのページは周辺の習慣を扱います。どのwaitを書く価値があるかも含みます。

セッションが普段使っているブラウザ側にある場合

PlaywrightのstorageStateファイルは、自分で所有するものです。アカウントがフィクスチャで、パスワードがCI secretsにあり、マシンの前に誰もいないなら、正しい道具です。ログインが自分のものなら、間違った道具です。SSO、ハードウェアキー、プッシュプロンプト、日常のChromeですでに温めてあるセッションです。その場合の仕事はログインを組み立て直すことではありません。すでに持っているブラウザを借りることです。

ego (lite) がその借用です。エージェントは、すでに使っているサインイン済みタブを開き、マウスを奪わないSpaceで作業を続け、第二要素や支払い確認があなたを必要としたら止まります。セッションがディスク上のJSONファイルになることはありません。ここが要点です。個人のログインはブラウザに残すべきであり、あとでCIが誤ってコミットしそうなstorageStateパスを通してはいけません。

Claude Codeが完了したAirbnb物件比較を示し、横ではairbnb.com.sg上のライブなego (lite) Spaceがあり、Agent is in controlとTake overが見える
サインイン済みの Airbnb セッションはブラウザに残りました。Agent は Agent is in control と Take over が見える Space で作業しました。

2つの道具は分けておきます。無人のテストアカウントはstorageStateに残します。Playwright認証ガイドが説明する通りです。個人のダッシュボードは見えるブラウザに残します。ego (lite) はMFAをクリックせず、支払いを確認せず、資格情報を集めません。これらの停止点はSpaceドキュメントクイックスタートに書かれています。現在の製品バージョンは0.5.0.32です(changelog、2026-09-12)。新しいビルドを引用する前に、changelogGitHubリポジトリをもう一度確認してください。

FAQ

storageStateを保存したあとでも、Playwrightがログインページに着くのはなぜか

storageStateのあともログインページに着くのは、ファイルを保存するのが早すぎた、contextが一度も読み込まなかった、サーバーがCookieを失効させた、のいずれかです。まずファイルのCookieを見て、次にcontextのオプションを見ます。どちらも正しそうなら、ナビゲーションのバグではなく死んだセッションとして扱います。

セッションCookieはstorageStateを生き延びるか

生き延びます。ファイルは有効期限の有無に関係なくCookieを記録し、オリジンごとのlocalStorageも記録します。生き延びないのは、ブラウザがその構造の外に置くものです。IndexedDB、session storage、キャッシュ、service workerです。

保存したPlaywrightログインはどれくらい持つか

サーバーがそのセッションを受け取り続ける限りです。それはサーバー側の判断であり、いつでも覆せます。Cookieの有効期限は上限であり、約束ではありません。長く置いた状態ファイルは、プローブがそうでないと言うまで古いものとして扱います。

並列workerは1つのstorageStateファイルを共有できるか

読むことはでき、各workerのcontextは隔離されます。問題は、あるworkerが再ログイン後にファイルを更新し、別のworkerが実行途中のときに起きます。各workerに独自のコピーを渡すか、更新を直列化して、一度に1回しかログインしないようにします。

PlaywrightでOAuthやSSOを認証するには

IDプロバイダのCookieを残すcontextで、リダイレクト連鎖を一度最後まで完走し、アプリの最終URLに着いてから状態を保存します。プロバイダが対話的な手順を求めるなら、同じ実行の中で手作業で一度行い、その後は得られた状態を再利用します。

MFAとワンタイムコードはどう扱うべきか

保存したコードで第二要素を自動化してはいけません。正直な型は、結果が保存済みセッションになる一度きりの人手手順か、自分のブラウザですでに認証済みのセッションです。OTPの秘密やSMSコードを状態ファイルの隣に置くと、セキュリティ制御が負債になります。

storageStateをバージョン管理へコミットしてよいか

よくありません。ファイルには生きたセッションCookieが入っています。作業ディレクトリに置き、gitでは無視し、CIではシークレットストアから注入します。一度でもコミットしたことがあるなら、セッションは漏洩したとみなし、失効させます。

期限切れセッションを自動化が扱えることを、意図的にどうテストするか

フィクスチャのrevokeエンドポイントと同じく、サーバー側でセッションを無効化するテストフックを追加し、再利用経路をそれに対して実行します。リダイレクト、プローブの401、復旧をアサートします。この1本のテストが、本番でしか発火しないコードパスをカバーします。

アプリがTokenをIndexedDBに置く場合はどうするか

storageStateはそれを運びません。道は2つです。ログイン後にページスクリプトでTokenを取得し、再利用時に再注入するか、Tokenを発行するAPIで各実行の中で認証するかです。前者は速く、後者はアプリ内部への結合が少ないです。

状態を再利用せず、毎回再認証すべきか

使い捨てのテストアカウントを使うCIでは、実行ごとの再認証のほうが安全な既定です。安定したセッションに対して頻繁に走るジョブでは、再利用にプローブと上限付き再ログインを足すほうが安く、考えやすいです。どちらも正当です。確認なしの再利用は正当ではありません。

ローカルではログインでき、CIでは失敗するのはなぜか

多くの場合、CI側の状態ファイルが無いまたは古い、時計が違う、新しいIPに別バリアントのログインページが返る、のいずれかです。同じヘッドレス設定、viewport、状態ファイルで再現し、スイート実行前にセッションをプローブします。失敗はコード差ではなく、環境であることが多いです。

1つのstorageStateファイルで2つのアカウントを扱えるか

扱えません。マージしようとすると、最後に書かれたCookieが残ることが多いです。アカウントまたはロールごとにファイルを1つにし、属する身元で名前を付け、各contextへ正しいほうを渡します。