Article

Give Your Coding Agent a Session, Not a Password

Oct 27, 2026 • 7 min read

My coding agent could build a feature, deploy it to a dev VM and open it in a browser. Then it hit the login screen. How I got it logged in without handing it the password.

On this page

Share

The last manual step in my unattended agent workflow was the login screen.

Here is what my coding agent did once that step was gone. It deployed a branch to a dev VM, opened the deployed app already logged in, and walked through the dashboards and data pages. Of the 68 API calls those pages made, 67 succeeded. The one 404 was a real finding: an endpoint missing on the VM. It added a record, edited it, and hard-reloaded the page to prove both changes had reached the server. Then it went to delete the record, hit a confirmation that said the delete could not be undone, and stopped to ask me.

Before this, every run stopped much earlier, at a form asking for an email and a password. I logged it in by hand and it carried on, which meant nothing ran while I was away.

Why not just give it the password

The obvious fix is to put the test account's password somewhere the agent can read it and let it fill in the form.

That trains the wrong habit. An agent that types credentials into whatever page is in front of it will one day do that on a page it should not trust. The agent I use refuses to type a password into a page on a remote host, and I would rather keep that refusal than find a way around it.

What I wanted was narrower: the agent gets a logged-in session, and never has to see or type the password.

The design

Let a process I set up do the login, with the password coming from the operating system's credential store, and hand the browser only the result.

The password goes from your terminal into the OS credential store and on to the dev server, which logs in from Node. Only the resulting session token reaches the browser, and the agent just opens localhost

It has five parts.

  1. A profile file with no secrets in it. It maps a short name like vm1 to a backend URL and a test account email. It is safe to commit.
  2. The password lives in the OS credential store. That is Keychain on macOS, Credential Manager on Windows and Secret Service on Linux. I type it once, into my own terminal, with no echo.
  3. The dev server logs in from Node. A Vite plugin (a hook into the dev server that serves the app) calls the same endpoints the login form calls, using the stored password, and caches the session it gets back.
  4. The session goes in before the app boots. The plugin adds a small inline script to the top of <head>. It writes the token to localStorage (the browser's per-site key-value store, where this app keeps its session) before any of the app's code runs. The app starts up already logged in.
  5. The agent opens localhost. No form, no credentials, nothing to type.

The plugin itself is not published. It is wired to one app's login API and storage, so the pattern travels better than the code. None of it depends on Vite either: any dev server that can run your code before it serves HTML can do the same, such as webpack-dev-server's middleware hook. For anything else, the proxy mode below works unchanged, because it sits in front of whatever serves the page.

Step 4 works because of script order. A plain inline <script> runs the moment the browser parses it, while the app's bundle is a module script, and module scripts wait until the page has been parsed. By the time the app reads localStorage, the token is already there.

Storing the password is a one-off:

$ npx agent-login set vm1
Password for qa@example.com (vm1):
Saved to the macOS Keychain.

On macOS I read it back through the security command-line tool. An item that security wrote can be read by security without the Keychain asking for permission, so nothing pops up mid-run. That convenience cuts both ways, which I come back to under Limits.

Testing what is deployed: the proxy

The dev server covers one case: my local frontend code against the VM's backend. Often I want the other one, the build that is deployed on the VM. For that there is a second mode.

Serve mode runs local code on a Vite dev server that injects the session and proxies API calls to the VM. Proxy mode puts a Node reverse proxy on localhost in front of the deployed build and injects the session into HTML only

proxy starts a small reverse proxy (a local server that forwards every request to another server and passes the reply back) on localhost. It forwards everything to the VM unchanged, except HTML responses, which get the same session script. It also rewrites redirects and cookie domains so they point back at localhost instead of the VM.

That covers everything except behaviour tied to the VM's real hostname. Single sign-on callbacks, for example, still expect the real address.

Why not Playwright's storageState

Playwright, the browser automation library, can save a logged-in browser's cookies and localStorage to a file and load them into the next browser it launches. That is the usual answer, and it did not fit for two reasons.

It only applies to browsers Playwright launches, and my agent drives its own. And it is a snapshot. Every redeploy killed the saved token, so I would be back to running a login script, with the password in an environment variable, after each deploy. Injecting from the server means a fresh session gets fetched whenever the old one dies, whichever browser opens the page.

What broke along the way

Match the format exactly. My first version wrote the token, and the app still showed a logged-out user. The app saved its token with JSON.stringify, so a real login left "abc123" in storage, quotes included. I was writing abc123. The app tried to parse my value as JSON, failed, and quietly decided nobody was logged in. Before injecting anything, log in by hand once and copy exactly what ends up in storage.

Never overwrite a human's login. If I log in by hand in the same browser, my session should win. So the script remembers which token it injected, and only replaces a session that is still its own:

(() => {
  // json is JSON.stringify(session) with every "<" written as \u003c,
  // so a "</script>" inside a value cannot end this script early.
  const session = ${json};
  const current = localStorage.getItem("session");
  const ours = localStorage.getItem("agentLogin.injected");
  if (current && current !== ours) return; // someone logged in by hand
  localStorage.setItem("session", session.token);
  localStorage.setItem("agentLogin.injected", session.token);
})();

Redeploys kill every token. Each deploy to the VM invalidated every session, and the cached one kept getting injected long after it had died. The dev server now checks the cached session with a cheap "who am I" request, at most every few seconds, and throws it away the moment any authenticated request comes back 401 (the "you are not logged in" status). The next page load logs in again on its own.

Guard the post-login redirect. The login flow takes a "where to go next" path, and an unchecked one is an open redirect. Only same-origin paths get through. //evil.example and /\evil.example both look like paths but browsers treat them as another host, so they are rejected too:

function safeNext(next) {
  if (typeof next !== "string" || !next.startsWith("/")) return "/";
  if (next.startsWith("//") || next.startsWith("/\\")) return "/";
  return next;
}

The part where it stopped

The delete confirmation is the moment I keep coming back to. Stopping there is the behaviour I want. Getting the agent logged in did not mean handing it every decision, and irreversible ones stay with a person. The lesson for me: when an agent creates test data, decide up front how that data gets cleaned up.

Limits

  • Password accounts only. One-time codes, single sign-on and multi-factor login cannot be automated this way, and should not be.
  • Dev servers only. The plugin runs on the local dev server, is opt-in through an environment variable, and is not part of any build. Nothing here reaches production.
  • The password is out of the agent's path, not out of its reach. It never lands in the browser, the repository, a config file, a log or the agent's context by accident. It does not stop an agent that goes looking: the agent has a shell, and the same security command my dev server runs would hand it the password. What protects against that is the agent's own rules and a test account worth very little, not this setup.
  • The token is still a credential. It sits in localStorage, in a browser the agent drives, and whoever holds it is logged in until it expires. This shrinks the blast radius to one test account's session on a dev VM. It does not remove it.

The agent still cannot log in. It does not need to. It needs to be logged in, and that turned out to be a much smaller thing to give it.

Related posts

View all

Aug 19, 2026 • 11 min read

The Session Writes the Note

I already wrote notes while I worked. They were short, and sometimes missed the part that mattered. Four weeks of handing that job to the session itself.

  • Claude Code
  • Obsidian
  • AI Agents
  • Automation
  • Developer Experience
Read article