If you build automation that logs into third-party websites, the dangerous shortcut is obvious: save the username and password, then replay them whenever the job runs. It works, until a log line, API response, support tool, database dump, or overbroad internal permission turns that stored password into a real incident.
Treat the password as a login event, not stored state
A safer model is to treat the password as input to one operation: establishing a session. After login succeeds, store the browser session state, not the password.
The basic flow looks like this:
- User enters credentials.
- Your service uses them to perform an interactive login.
- Your service captures cookies and local storage from the browser context.
- Your service encrypts that session state and stores it with tenant and user scope.
- Your service discards the password.
- Future jobs load the encrypted session state instead of asking for the password again.
Here is a simplified Node.js example using Playwright and AES-256-GCM:
import { chromium } from "playwright";
import crypto from "node:crypto";
function encryptJson(value: unknown, key: Buffer) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const plaintext = Buffer.from(JSON.stringify(value), "utf8");
const ciphertext = Buffer.concat([
cipher.update(plaintext),
cipher.final()
]);
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
}
async function connectAccount({
tenantId,
userId,
username,
password,
encryptionKey
}: {
tenantId: string;
userId: string;
username: string;
password: string;
encryptionKey: Buffer;
}) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto("https://example.com/login");
await page.fill("input[name=email]", username);
await page.fill("input[name=password]", password);
await page.click("button[type=submit]");
await page.waitForURL("**/dashboard", { timeout: 15000 });
const storageState = await context.storageState();
const encrypted = encryptJson(storageState, encryptionKey);
const sessionId = await db.sessions.insert({
tenantId,
userId,
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
status: "active",
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
});
return { sessionId, status: "connected" };
} finally {
await browser.close();
}
}
Do not overstate what this does. Setting a JavaScript variable to undefined does not guarantee memory erasure. The important design choice is that you never write the password to disk, never return it from an API, never log it, and never keep it as reusable application state.
Sessions are credentials too
A browser session can contain cookies, JWTs, refresh tokens, CSRF tokens, and site-specific state in local storage. If someone steals it, they may not need the password.
So session storage needs nearly the same controls as password storage:
CREATE TABLE browser_sessions (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
ciphertext text NOT NULL,
iv text NOT NULL,
auth_tag text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'revoked', 'expired')),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE INDEX browser_sessions_tenant_id_idx
ON browser_sessions (tenant_id, user_id, status);
Every read should include tenant scope. Do not fetch by session ID alone and then check ownership later. That creates room for mistakes in future call sites.
SELECT *
FROM browser_sessions
WHERE id = $1
AND tenant_id = $2
AND user_id = $3
AND status = 'active'
AND expires_at > now()
AND revoked_at IS NULL;
This is also where API shape matters. Return handles and metadata, not secrets:
{
"sessionId": "6b9f...",
"status": "active",
"expiresAt": "2026-03-01T00:00:00Z"
}
Do not return encrypted session blobs to the frontend “because they are encrypted.” If the client does not need the value, the API should not expose it.
For authenticated browser jobs, Wire follows this session-based model: the automation runs against an encrypted browser session rather than keeping a reusable site password around.
Expiry and revocation need first-class behavior
Sessions expire. Sites rotate cookies. Users change passwords. MFA prompts appear. A good system treats these as normal states, not weird failures.
Bad behavior looks like this:
Error: Timeout 30000ms exceeded
That tells the user nothing. Did the page load slowly? Did login expire? Did the selector change? Did MFA block the run?
Better behavior distinguishes the failure:
if (page.url().includes("/login")) {
throw new SessionExpiredError({
code: "SESSION_RECONNECT_REQUIRED",
message: "The saved browser session is no longer valid. Reconnect the account."
});
}
Revocation should also take effect before the next job starts, not eventually after some background cleanup runs:
UPDATE browser_sessions
SET status = 'revoked', revoked_at = now()
WHERE id = $1
AND tenant_id = $2
AND user_id = $3;
Then your job runner must refuse to load revoked sessions. This sounds obvious, but many systems implement revocation only in the UI and forget the worker path.
Logs are the easiest place to leak secrets
Even if the database design is good, logs can undo it. The common mistake is logging request bodies during login debugging:
logger.info({ body: req.body }, "connect account request");
That can put passwords, cookies, or tokens into your observability system. Use structured redaction by default:
import pino from "pino";
export const logger = pino({
redact: {
paths: [
"req.headers.authorization",
"req.body.password",
"password",
"cookies",
"storageState.cookies",
"storageState.origins[*].localStorage[*].value"
],
censor: "[REDACTED]"
}
});
Also check error reporting. Browser automation failures often include screenshots, HTML snippets, network headers, and console logs. Any one of those can contain account data.
Isolate the browser runtime
Do not reuse one persistent browser profile across users or tenants. Create a fresh context for each job, load only the specific session state needed for that job, then tear it down.
const context = await browser.newContext({ storageState });
try {
await runAutomation(context);
} finally {
await context.close();
}
This does not solve every problem. The target site can still rate limit you. MFA can still interrupt execution. A compromised automation script can still misuse the access it has during the run. But isolation reduces accidental cross-account state bleed, which is one of the nastier classes of bugs in browser automation systems.
Wire runs authenticated tasks in scoped browser environments, which addresses the specific risk of one customer’s browser state carrying into another job context.
A practical checklist
If you are reviewing or building this kind of system, trace one account connection from end to end and answer these questions:
- Is the password ever written to disk?
- Can any API response include a cookie, token, or encrypted session blob?
- Do all session reads include tenant and user scope?
- What does the worker do when the site redirects to login?
- Does revocation block future runs immediately?
- Are request bodies, screenshots, HTML dumps, and network headers redacted?
- Does each job get a fresh browser context?
Start by testing the failure paths. Expire a session manually, revoke one while a job is queued, and search your logs for the test password. That will tell you more than the happy path ever will.
Top comments (0)