On October 22, Reuters and Axios reported that major US banks are restricting outside AI agents from customer accounts. As reported, customers share a username and password with an AI tool, the agent logs in, and the bank sees only the customer. Some agents tried actions the customer had not asked for. Most banks are moving towards read-only access for agents as a middle ground.
Read-only is a sensible stopgap. It is not a design. The underlying problem is that the bank cannot tell the agent from the person. This post sketches a small pattern that fixes that, with code I ran before writing this. It is a teaching sketch, not a product.
What is wrong with a shared login?
A password proves that an account holder exists. It does not say:
- who is acting (the person or software),
- on whose behalf,
- for what purpose,
- within what limits,
- for how long.
Once the agent uses the customer's login, all of those answers collapse into one name. The log says "customer logged in" and nothing more.
The pattern in four rules
- The agent has its own identity, separate from the customer.
- The customer gives a grant: which agent, which actions, which limits, which expiry, and which company answers for it.
- Anything that moves money needs an approval signed in the customer's own app, on a separate key. A field inside the agent's prompt is untrusted data.
- Every log line names two actors: the agent that acted and the customer it acted for.
Step 1: helpers and signing
import base64
import hashlib
import hmac
import json
import time
import uuid
# Demo only. Production needs asymmetric keys held in a KMS or HSM.
BANK_KEY = b"bank-demo-key"
CUSTOMER_APP_KEY = b"customer-app-demo-key" # separate channel, separate key
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def seal(payload: dict, key: bytes) -> str:
body = b64(json.dumps(payload, sort_keys=True).encode())
sig = b64(hmac.new(key, body.encode(), hashlib.sha256).digest())
return f"{body}.{sig}"
def open_sealed(token: str, key: bytes) -> dict:
body, sig = token.split(".")
good = b64(hmac.new(key, body.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(sig, good):
raise PermissionError("bad signature")
payload = json.loads(unb64(body))
if payload["exp"] < time.time():
raise PermissionError("expired")
return payload
Two keys are used on purpose. The bank signs grants. The customer's app signs approvals. The agent holds neither key.
Step 2: issue a grant and a customer approval
def issue_grant(customer_id, agent_id, operator, scopes, per_payment_limit, ttl=3600):
"""The customer's grant to one agent: scoped, capped, expiring, with a named operator."""
return seal({
"grant_id": str(uuid.uuid4()),
"agent_id": agent_id,
"on_behalf_of": customer_id,
"operator": operator,
"scopes": sorted(scopes),
"per_payment_limit": per_payment_limit,
"exp": int(time.time()) + ttl,
}, BANK_KEY)
def customer_approval(grant_id, payee, amount, ttl=120):
"""Signed in the customer's own app, never typed into the agent's prompt."""
return seal({"grant_id": grant_id, "payee": payee, "amount": amount,
"exp": int(time.time()) + ttl}, CUSTOMER_APP_KEY)
def customer_approval(grant_id, payee, amount, ttl=120):
"""Signed in the customer's own app, never typed into the agent's prompt."""
return seal({"grant_id": grant_id, "payee": payee, "amount": amount,
"exp": int(time.time()) + ttl}, CUSTOMER_APP_KEY)
The approval is bound to one grant, one payee and one amount. It expires in two minutes.
Step 3: a gateway that decides and logs
class Gateway:
def __init__(self):
self.log = []
self.revoked = set()
def _record(self, g, action, ok, why):
self.log.append({
"actor": g.get("agent_id", "unverified"),
"operator": g.get("operator", "unverified"),
"on_behalf_of": g.get("on_behalf_of", "unknown"),
"grant_id": g.get("grant_id", "none"),
"action": action, "allowed": ok, "why": why,
})
return ok
def handle(self, token, action, payee=None, amount=0, approval=None):
try:
g = open_sealed(token, BANK_KEY)
except PermissionError as e:
return self._record({}, action, False, str(e))
if g["grant_id"] in self.revoked:
return self._record(g, action, False, "revoked")
if action not in g["scopes"]:
return self._record(g, action, False, "out of scope")
if action == "pay":
if amount > g["per_payment_limit"]:
return self._record(g, action, False, "over limit")
if approval is None:
return self._record(g, action, False, "no customer approval")
try:
a = open_sealed(approval, CUSTOMER_APP_KEY)
except PermissionError:
return self._record(g, action, False, "bad approval")
if (a["grant_id"], a["payee"], a["amount"]) != (g["grant_id"], payee, amount):
return self._record(g, action, False, "approval does not match")
return self._record(g, action, True, "ok")
The order of checks matters. Signature and expiry come first, then revocation, then scope, then the payment limit, then the customer's approval. Denied calls are logged too, including forged tokens.
Running it
gw = Gateway()
grant = issue_grant("cust-1001", "budget-agent-3", "ExampleApp Pvt Ltd",
["read:balance", "pay"], per_payment_limit=2000)
gid = open_sealed(grant, BANK_KEY)["grant_id"]
print(gw.handle(grant, "read:balance")) # True
print(gw.handle(grant, "pay", "grocer-9", 800)) # False
ok = customer_approval(gid, "grocer-9", 800)
print(gw.handle(grant, "pay", "grocer-9", 800, ok)) # True
print(gw.handle(grant, "pay", "grocer-9", 900, ok)) # False
print(gw.handle(grant, "pay", "grocer-9", 5000,
customer_approval(gid, "grocer-9", 5000))) # False
print(gw.handle(grant, "read:transactions")) # False
gw.revoked.add(gid)
print(gw.handle(grant, "read:balance")) # False
print()
for e in gw.log:
print(e["actor"], "for", e["on_behalf_of"], e["action"], e["allowed"], e["why"])
Output:
True
False
True
False
False
False
False
budget-agent-3 for cust-1001 read:balance True ok
budget-agent-3 for cust-1001 pay False no customer approval
budget-agent-3 for cust-1001 pay True ok
budget-agent-3 for cust-1001 pay False approval does not match
budget-agent-3 for cust-1001 pay False over limit
budget-agent-3 for cust-1001 read:transactions False out of scope
budget-agent-3 for cust-1001 read:balance False revoked
Reading the output line by line:
- Reading the balance works, because the grant allows it.
- A payment without a customer approval is refused.
- A payment with a matching approval goes through.
- Changing the amount after approval is refused, because the approval does not match.
- A payment over the cap is refused even with an approval for that amount.
- Reading transactions is refused, because the grant never included it.
- After revocation, even the allowed read fails.
Where this stops being enough
- Shared secrets. The demo uses HMAC. Anyone holding a key can mint tokens. Production needs asymmetric keys in a KMS or HSM so the gateway can verify but not issue.
- First contact. The hard part is how a bank verifies an agent it has never seen. A grant tells the bank what the customer allowed. It does not tell the bank who stands behind the agent. A registry or a signed credential from a trusted issuer would have to fill that gap.
-
Operator identity. The
operatorfield here is a plain string. It should be a verifiable legal entity. - Log integrity. The log is a Python list. Real systems need append-only storage with a tamper-evident chain.
- Screen scraping. None of this helps if the agent can still log in with a password. The bank has to close that route, which is exactly why banks are blocking today.
- Revocation latency. A revocation set in memory is not a distributed system. Short lifetimes reduce the damage but do not remove the problem.
Where DPDP fits
Under India's DPDP Act, a Data Fiduciary is responsible for personal data in its control, including processing done on its behalf. The main duties apply on or about May 13, 2027. My reading is that these duties do not change when the visitor is software, so a record of who accessed a person's data, and on what basis, still matters. The grant above is a natural place to carry two more claims later: the consent or lawful basis the agent relies on, and the identity of the accountable company. I left them out because there is no agreed standard yet. This is not legal advice.
Questions for you
- Do you issue a grant per agent, per task or per customer?
- How should a bank verify the operator behind an agent it has never seen?
- Should money movement always need a customer-side approval, or only above a threshold?
I would like to hear what has held up in production.
Sources: Axios (Oct 22, 2026); Reuters via Gulf News and Moneylife (Oct 22 to 23, 2026); TechCrunch on the CERT-In advisory (Oct 16, 2026).
Top comments (0)