"Store the user's login" sounds like one database row until the system has to handle real accounts. A customer has two accounts on the same supplier portal. A password rotates in 1Password. Someone imports a browser session because the password flow has MFA. A worker reuses cookies from a different IP and the site silently sends it back to login. If your model is just user_id, site, username, password, and cookies, you cannot answer the basic question: what account is this, what secret produced this session, and is it safe to refresh?
A cleaner model is to split the problem into three things: identity, credential, and session.
Separate the account from the login method
An identity should mean one account on one site, owned by one user or tenant. It is not the human user, and it is not the password.
That distinction matters when a user has multiple accounts on the same site:
create table identities (
id uuid primary key,
owner_id uuid not null,
site text not null,
label text not null,
created_at timestamptz not null default now(),
unique (owner_id, site, label)
);
create type credential_kind as enum ('CREDENTIALS', 'BROWSER_STATE');
create table credentials (
id uuid primary key,
identity_id uuid not null references identities(id) on delete cascade,
kind credential_kind not null,
vault_binding jsonb,
encrypted_browser_state bytea,
expires_at timestamptz,
exit_ip inet,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (identity_id, kind)
);
The useful constraint is unique (identity_id, kind). One account can have a password-derived credential and an imported browser-state credential at the same time, but it cannot have two competing password credentials unless you model that explicitly.
That forces callers to choose what they want:
await runTask({
identityId: "seller-us-prod",
credentialKind: "CREDENTIALS"
});
await runTask({
identityId: "seller-eu-prod",
credentialKind: "BROWSER_STATE"
});
Wire uses this identity and credential split so tasks can choose the exact account and session type instead of relying on a hidden default.
Put the vault binding on the credential
The tempting shortcut is to attach the vault item to the identity:
{
"identity": "supplier-account-123",
"onePasswordItemId": "abc123"
}
That works until it doesn't. The account is stable. The way you authenticate to it is not.
You might start with a manually entered password, move the secret into 1Password, migrate to Azure Key Vault, or split username and password into separate entries because that is how your organisation stores them. None of those changes should modify the account object. They change how the next session gets minted.
A credential-level binding gives you that boundary:
{
"identityId": "supplier-account-123",
"kind": "CREDENTIALS",
"vaultBinding": {
"provider": "1password",
"fields": {
"username": {
"itemId": "abc123",
"field": "username"
},
"password": {
"itemId": "abc123",
"field": "password"
}
}
}
}
It also lets you support messier vault layouts without changing the rest of the system:
{
"provider": "aws-secrets-manager",
"fields": {
"username": {
"secretId": "prod/supplier/username"
},
"password": {
"secretId": "prod/supplier/password"
}
}
}
The binding should name the item, not a version. When the password rotates in the vault, the next login reads the current value. You do not need to update your application database just because the secret changed.
You should also reject accidental rebinding. If an existing credential points at one vault item and a request tries to attach a different one, return a conflict instead of quietly changing which secret backs a live account:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "IDENTITY_BINDING_MISMATCH",
"message": "Credential is already bound to a different vault item"
}
That error saves you from a bad class of incident: the task still says "supplier production account", but the credential now logs into something else.
Treat sessions as derived state
A session is the thing that actually gets work done: cookies, tokens, local storage, and sometimes the proxy exit IP that created them.
That last field is easy to miss. Some sites bind sessions to network location. If you log in through one proxy exit and later send requests through another, the site may not return a clean 401. It might serve the login page with 200 OK, clear a cookie, or ask for MFA again. From the worker's point of view, the task just starts failing halfway through.
For password-derived sessions, store the exit IP that minted the session and reuse it:
type StoredSession = {
encryptedState: Buffer;
expiresAt?: Date;
exitIp?: string;
};
async function loadSession(credentialId: string) {
const credential = await db.credentials.find(credentialId);
return {
browserState: await decrypt(credential.encrypted_browser_state),
proxyExit: credential.exit_ip ?? undefined
};
}
Imported browser state is different. If a user uploads cookies from their local browser, you usually do not know the original exit IP. That session may pass every local test and then fail in production when a worker runs it from a data center IP. The model should make that difference visible rather than pretending all sessions have the same properties.
After each successful action, write updated cookies back to the credential. Many sites extend sessions on use, so this avoids unnecessary logins:
async function runWithStoredSession(credentialId: string, action: Action) {
const session = await loadSession(credentialId);
const browser = await launchBrowser({ proxyExit: session.proxyExit });
await browser.context().addCookies(session.browserState.cookies);
const result = await action(browser);
if (await looksLoggedOut(browser)) {
await markExpired(credentialId);
return { ok: false, error: "AUTH_EXPIRED" };
}
const updatedState = await captureBrowserState(browser);
await saveEncryptedState(credentialId, updatedState);
return { ok: true, result };
}
Do not hide relogin behind a background timer unless you have a strong reason. On-demand login has better audit properties: a vault read happens because a task requested access, not because a scheduler decided to refresh every account at 3 AM.
Wire handles expired sessions this way: the task gets AUTH_EXPIRED, and a fresh login reads the vault only when something asks for that account again.
The tradeoff is more explicitness
This model adds rows, constraints, and decisions. Callers need to specify the identity and credential kind. Operators need to understand the difference between deleting a session, deleting an account, and disconnecting a vault.
For a single internal app with one login per user, this may be overbuilt. A normal OAuth table might be enough.
But if you automate third-party sites, support multiple accounts per site, or read secrets from a vault, the separation pays for itself. Keep accounts stable, attach secret bindings to credentials, and treat sessions as replaceable derived state.
A practical next step: sketch your current login table and mark which columns describe the account, which describe the secret source, and which describe the live session. If one row mixes all three, that is where your next auth migration will probably come from.
Top comments (0)