Playwrightのログインで、パスワードを1Password CLIから必要なときだけ読み出す
管理画面から定期的にデータを取得する自作スクリプトを動かしていたところ、取得の途中で再認証を求められて止まるようになりました。認証済みのブラウザプロファイルを使い回していても、一定時間が経つとパスワードの再入力を求められるためです。
かといって、.envにパスワードを平文で置くのは避けたいところです。そこで、1Password CLIのop readを使って、ログインが必要になった瞬間にだけパスワードを読み出して入力するようにしました。
1Passwordを使っている環境なら、少ないコードで次の状態を作れます。
- パスワードをファイル・環境変数・ログに残さない
- 設定ファイルには
op://の参照だけを書く - 読み出しには本人の承認(Touch IDなど)が必要になる
- 2段階認証は人が操作する
よくある方法との比較
スクリプトにパスワードを渡す方法としては、主に次の3つが考えられます。
| 方法 | 仕組み | 向いている場面 | 気をつける点 |
|---|---|---|---|
.envに平文 | ファイルから読み込む | 手軽に試したいとき | ファイル・シェル履歴・プロセスの環境変数に残る |
op run | 参照を解決し、子プロセスの環境変数として渡す | 起動時に値が決まっているもの | 実際に使わなくても、起動時に読み出す |
op read(今回の方法) | 必要になった瞬間に標準出力から受け取る | ログインが必要なときだけ読みたいもの | 読み出しの処理を自分で書く必要がある |
op runは1Password CLIの標準的な方法で、次のように.envの参照を解決してコマンドを実行します。子プロセスの出力に含まれる秘密の値を、既定でマスクしてくれる点も便利です。
op run --env-file=.env -- node script.mjs
環境変数を読むだけのアプリケーションなら、まずはop runで十分だと思います。一方、今回のスクリプトでは再認証が起きない実行のほうが多く、そのときはパスワードを読み出す必要がありません。そこで「再認証が起きたときだけ読む」ためにop readを選びました。
仕組み
全体の流れは次のとおりです。ログイン画面へ飛ばされたことを検知してからop readを実行し、受け取ったパスワードをブラウザに入力します。
事前準備
1Passwordのデスクトップアプリと1Password CLIをインストールし、アプリの設定にあるDeveloperの項目で、Integrate with 1Password CLIを有効にします。macOSならTouch ID、WindowsならWindows Helloをアプリ側でも有効にしておきます。Linuxではシステム認証を使います。
次に、パスワードの参照を.envに書きます。これは保管場所を指す文字列であり、秘密の値そのものではありません。
# .env。参照であって秘密の値ではないLOGIN_PASSWORD_REF="op://Private/Example/password"
参照はop://<保管庫>/<アイテム>/[セクション/]<フィールド>という形式です。英数字とハイフン、アンダースコア、ピリオド、空白以外の文字を含む名前は、名前の代わりに一意のIDで指定します。
サンプルコード
Node.jsで動くスクリプト全体です。以下はPlaywrightをライブラリとして直接使う例で、TARGET_URLとLOGIN_ORIGINはご自身の環境に合わせて書き換えてください。
import { execFile } from 'node:child_process'import { promisify } from 'node:util'import { chromium } from 'playwright'
const TARGET_URL = 'https://app.example.com/reports'const LOGIN_ORIGIN = 'https://accounts.example.com'const LOGIN_TIMEOUT_MS = 5 * 60_000const HEADLESS = process.env.HEADLESS === '1'
const execFileAsync = promisify(execFile)
const isLoginPage = (url) => new URL(url).origin === LOGIN_ORIGIN
// op read に任意の文字列を渡さないconst isOpReference = (value) => /^op:\/\/[^/]+\/[^/]+\/.+$/.test(String(value ?? ''))
// ログに出してよい理由だけを code に入れるconst safeError = (code) => Object.assign(new Error(code), { code })
const fillPassword = async (page, passwordRef) => { if (!isOpReference(passwordRef)) { throw safeError('INVALID_OP_REFERENCE') }
const input = page.locator('input[type="password"]') await input.waitFor({ state: 'visible', timeout: 30_000 })
// シェルを介さない execFile で、参照を引数として渡す const { stdout: password } = await execFileAsync('op', [ 'read', '--no-newline', passwordRef ])
// 承認を待つ間に遷移している場合があるので、入力の直前に確かめる if (!isLoginPage(page.url())) { throw safeError('UNEXPECTED_PAGE') }
await input.fill(password) await input.press('Enter')}
const reauthenticate = async (page, passwordRef) => { try { await fillPassword(page, passwordRef) } catch (e) { // e.message には入力値が含まれうるので出さない const reason = e.stderr?.trim() || e.code || e.name console.error(`自動で入力できませんでした(${reason})`)
if (HEADLESS) { throw new Error('ヘッドレス実行のためログインを続行できません') } }
// 自動入力に失敗した場合は、人がブラウザでログインするのを待つ await page.waitForURL((url) => !isLoginPage(url.href), { timeout: LOGIN_TIMEOUT_MS })}
const context = await chromium.launchPersistentContext('.browser-profile', { headless: HEADLESS})
try { const page = context.pages()[0] ?? (await context.newPage()) await page.goto(TARGET_URL)
if (isLoginPage(page.url())) { await reauthenticate(page, process.env.LOGIN_PASSWORD_REF) }
// ここから本来の処理 console.log(await page.title())} finally { await context.close()}
Node.js 20.6以降なら、--env-fileで.envを読み込めます。
node --env-file=.env login.mjs
動作はPlaywright 1.64.1とダミーのログインページで確認しました。opはスタブに差し替え、次の経路を確かめています。
| 条件 | 結果 |
|---|---|
| 読み出しに成功 | 入力して送信し、元のページへ戻る |
opが失敗 | 通常の実行では手動ログインを待ち、ヘッドレスではエラーで終了する |
| 参照の形式が不正 | opを呼ばず、自動入力を中止する |
| 承認待ちの間に別のページへ遷移 | パスワードを入力せずに中止する |
実際の1Passwordアプリでの承認までは、この検証には含まれていません。opの失敗時の出力のみ、実物のCLIで確認しています。
実装の要点
参照の形式を検証してから渡す
.envの値をそのままop readに渡さず、op://で始まる参照の形式かどうかを確かめています。設定の書き間違いなどで、意図しない文字列がコマンドの引数に入るのを防ぐためです。
execFileでシェルを介さず実行する
execはシェルを介してコマンドを実行するため、文字列の内容によってはシェルに解釈されてしまいます。execFileならシェルを介さず、参照を1つの引数として渡せます。
--no-newline(-n)は、出力の末尾に改行を付けないためのオプションです。受け取った値をそのまま入力に使うので指定しています。
例外のmessageをそのままログに出さない
意外と見落としやすいのが、Playwrightのエラーのmessageです。messageには呼び出しログが含まれ、そこにfillの入力値が入ることがあります。実際に、読み取り専用の入力欄にfillしてタイムアウトさせると、次のようなmessageになりました。
locator.fill: Timeout 1000ms exceeded.Call log: - waiting for locator('#a') - locator resolved to <input id="a" readonly type="password"/> - fill("SuperSecret-XYZ-123") - attempting fill action
ダミーの値ですが、入力しようとした文字列がそのまま出ています。console.error(e)のように例外全体を出力しても同じです。
そのためサンプルではログに出す項目をopの標準エラー出力、エラーコード、エラー名に絞っています。スクリプト自身が投げる例外も、safeErrorでcodeに理由を入れることで、同じ経路で出力できます。
失敗時の手動ログインへの切り替え
自動入力に失敗しても終了せず、人がブラウザでログインするのを待ちます。Touch IDの承認をキャンセルした場合や、ログイン画面の構成が変わった場合でも、そのまま作業を続けられます。
ただし、ヘッドレス実行では人が操作できません。待っても無駄なので、タイムアウトを待たずにエラーで終了します。
入力の直前に入力先を確かめる
fillの直前に、ページのオリジンが想定どおりかを確認しています。op readで承認を待っている間にページが変わる可能性があるので、読み出しの後で確かめる順番にしています。
注意点
承認の単位
「読み出すたびにTouch IDが必要」と思われがちですが、公式ドキュメントによると、そうではありません。
- 承認はアカウントごと、かつターミナルのウィンドウやタブごとに行う
- macOSとLinuxでは、そのウィンドウのサブシェルにも承認が引き継がれる(Windowsでは別途承認が必要)
- 10分間使わないと切れる(使うたびに延長される)
- 使い続けていても、最長12時間で再承認になる
つまり、同じターミナルで短い間隔でスクリプトを実行している間は、承認なしで読み出せます。スクリプトの実行ごとに本人確認が入るわけではない点には注意が必要です。
保護が及ぶ範囲
パスワードをファイルに置かずに済むのは確かですが、秘密が残らないのはディスク上の話で、すべての経路から守られるわけではありません。
- 入力の間はスクリプトのメモリに平文で載る
- 同じ権限で動く悪意あるプロセスまでは防げない
- 公式ドキュメントでも、root / 管理者権限を持つユーザーやアプリケーション、macOSのアクセシビリティ権限を持つアプリは、保護を回避できる可能性があると説明されている
また、パスワードが残らなくても、.browser-profileにはログイン済みのセッション情報が保存されます。プロファイルのディレクトリは誤ってコミットしたり共有したりしないように扱う必要があります。
Playwrightの成果物は別に考える
Playwrightの公式ドキュメントでも、トレースやレポート、コンソールログには機密情報が含まれる場合があるとして、慎重な扱いが推奨されています。
手元のPlaywright 1.64.1でも、fillを記録したトレースのzipを展開すると、入力値が含まれていました。ログイン処理の間はトレースを取らないようにしておくと安心です。tracing.startChunk()とtracing.stopChunk()を使えば、記録する区間を分けられます。
await context.tracing.start({ snapshots: true })
await context.tracing.startChunk()await page.goto(TARGET_URL)await context.tracing.stopChunk({ path: 'before-login.zip' })
// この区間は記録しないif (isLoginPage(page.url())) { await reauthenticate(page, process.env.LOGIN_PASSWORD_REF)}
await context.tracing.startChunk()// ここから本来の処理await context.tracing.stopChunk({ path: 'after-login.zip' })
await context.tracing.stop()
なお、入力値のマスクを求めるmicrosoft/playwright#38673を受けて、2026年10月にテストランナー向けのsecretsオプションが追加されました。レポーターとトレースのそれぞれで、指定した値を<secret>NAME</secret>のように置き換えられます。
import { defineConfig } from '@playwright/test'
export default defineConfig({ reporter: [ ['html', { secrets: [{ name: 'PASSWORD', value: process.env.PASSWORD }] }] ], use: { trace: { mode: 'on', secrets: [{ name: 'PASSWORD', value: process.env.PASSWORD }] } }})
ただし、執筆時点ではまだ正式リリースに含まれていません。最新の安定版1.64.1の型定義にはなく、1.65.0-alpha-2026-10-30で確認できる段階です。今後変わる可能性もあるので、1.65のリリースノートを確認してから使うのがよさそうです。
このオプションは@playwright/testの設定なので、今回のようにライブラリとして使うスクリプトのtracing.start()には指定できません。また、設定の時点で秘密の値が必要になるので、必要なときだけ読むという今回の方法とは噛み合わない部分もあります。ドキュメントによると、スクリーンショットや動画も置き換えの対象外です。
無人実行
CIなどの無人環境ではTouch IDで承認できません。この方法は人が手元で実行するスクリプト向けです。
無人で実行したい場合は1Passwordのサービスアカウントという仕組みがありますが、人の承認がなくなるので、また別の考え方が必要になります。この記事では扱わないでおきます。
利用規約と2段階認証
サービスによっては、自動ログインを規約で禁じていたり、自動操作を検知して拒否したりする場合があります。使う前に、対象のサービスの規約を確認してください。
また、この記事では2段階認証の自動化や回避は扱いません。2段階認証は人が操作する前提です。
まとめ
1Password CLIのop readを使うと、.envなどに平文のパスワードを置かず、ログインが必要な瞬間にだけ読み出せます。起動時に値が決まっているものにはop run、必要なときだけ読みたいものにはop read、という使い分けがしやすいと思います。
実装で特に気をつけたいのは、読み出した後の値の扱いです。Playwrightのエラーやトレースには入力値が入ることがあるので、例外のmessageをそのまま出力しないことと、ログイン処理を記録しないことを意識しておくと安心です。
参考リンク
- Secret references - 1Password Developer
- Secret reference syntax - 1Password Developer
- 1Password app integration - 1Password Developer
- App integration security - 1Password Developer
- Service accounts with 1Password CLI - 1Password Developer
- Locator: fill - Playwright
- Tracing - Playwright
- Properly handling Secrets - Playwright
- microsoft/playwright#38673
- child_process.execFile - Node.js