NanoTarget
Your customers sign in, then hand the session to an AI assistant. NanoTarget tells your server when that has happened and lets every endpoint decide what the assistant may see: allow, mask, step up or block — with a passkey that gives the session back to the person.
Concepts
Five words carry the whole system. Everything else is detail.
- Session
- One signed-in browser, across its tabs. You map a request to it with
identify(req)— your own user or session id — or let a first-party cookie do it. - Resource
- A named thing worth protecting:
balance.read,customer.read,report.export,transfer.create. Resources are yours to name; they are the unit a policy talks about. - Actor
- Who the engine believes is acting in the session right now: human-like, agent, or unknown. Unknown is its own answer — never a synonym for human.
- Decision
- What the endpoint does for this actor on this resource:
allow,mask,step_up,block. Every decision is recorded. - Policy
- A JSON file you own that maps resources to decisions, plus an enforcement mode:
observerecords what would have happened,enforceacts on it.
How it works
An agent operating a signed-in session is indistinguishable from the customer at the network layer: same cookies, same address, same browser. So NanoTarget does not try to read the request. It reads the session.
Two channels
A small first-party script watches the page for signs that something other than a person is operating it, and for evidence that a person is. It posts observations to your server, on your origin, as metadata. It makes no decisions and holds no secrets.
When a protected endpoint is called, the middleware asks the engine what it knows about this session. The answer combines everything observed so far with whatever accompanied this particular action.
What the engine weighs
Evidence is grouped by how much it proves, and the strongest class present decides:
| Class | What it means | Strength |
|---|---|---|
| Verified operator | The request carries a valid signature from a declared agent operator. | proof |
| Deterministic browser facts | Properties of the environment that a normal browsing session does not produce. | proof |
| Active-control indicators | Traces an agent tool leaves in the page while it is driving it. | strong |
| Behavioural evidence | How the session's interactions were physically produced. Judged by a model trained on labelled human and agent sessions. | strong |
| Environment traces | The session runs inside an AI application, or an agent tool is installed. A person may well be driving. | weak |
NanoTarget uses standard web APIs on your own origin. It does not fingerprint people, does not track across sites, and does not reverse-engineer any vendor's code. The exact measurements inside each class are the part we keep to ourselves — see the FAQ.
Three rules that shape every answer
- Unknown is never human. Absence of evidence selects the
onUnknownbranch of your policy — which you can make as strict as you like. An agent cannot become a person by staying quiet. - A person is recognised by positive evidence. Either the way their actions were physically produced, or a passkey. Claims from the page are never taken at face value.
- Attachment sticks. Once an agent has been seen in a session, the session stays that way until a person proves presence with WebAuthn — going quiet does not clear it.
Where enforcement happens
In your server process, before your handler runs. A blocked read never reaches serialization; a masked read is produced by your mask function on the server. The browser is never asked to keep a secret.
One thing does happen in the page: values already rendered before an agent appeared are redacted in place, because the server cannot recall what it already sent. That is damage control, not the security boundary. With the script removed, the server still answers exactly the same way.
Quickstart
Node ≥ 22.13. Works with Express 4/5, Connect, a Next.js custom server, or plain node:http.
- Install
npm i nanotarget
One package. It depends on
nanotarget-engine, which npm installs with it — you never add or import the engine yourself. Everything you use is undernanotarget/expressandnpx nanotarget. - See what an agent could reach
npx nanotarget scan . # routes, sensitivity, a draft policy npx nanotarget scan . --proposal # the same, in plain language
- Wire the middleware
import { nanotarget } from 'nanotarget/express'; const nt = await nanotarget({ secret: process.env.NT_SECRET, // ≥ 32 bytes, stable across restarts policy: './nanotarget.policy.json', db: 'sqlite:./nanotarget.db', apiKey: process.env.NT_API_KEY, // optional: report to the portal identify: (req) => req.session?.userId ?? null, }); app.use(nt.middleware()); app.get('/api/balance', nt.protect('balance.read'), (req, res) => nt.send(req, res, balance, (b) => ({ ...b, amount: null })));
- Add the page script and mark what is sensitive
<script src="/nanotarget/sdk.js"></script> // call protected endpoints through the SDK const r = await NanoTarget.fetch('/api/balance'); // rendered values that must disappear if an agent attaches <div data-nt-sensitive="full">$4,939.10</div>
- Verify
npx nanotarget verify http://localhost:3000 /api/balance
Policy reference
One JSON file. Unlisted resources are unprotected — list them in your own report deliberately.
{
"version": "bank-policy-1",
"enforcement": "observe",
"rules": [
{ "resource": "balance.read",
"title": "Account balance",
"onAgent": "mask",
"onArtifact": "mask",
"onUnknown": "allow",
"onHumanLike": "allow" },
{ "resource": "report.export",
"onAgent": "block", "onArtifact": "step_up",
"onUnknown": "step_up", "onHumanLike": "allow" }
]
}
| Field | Meaning |
|---|---|
enforcement | observe records the decision and lets the request through; enforce acts on it. |
onAgent | An agent is operating the session. |
onArtifact | Environment only: an AI application or an installed tool, no proof anything is driving. |
onUnknown | Not enough evidence in either direction. Never treated as human. |
onHumanLike | Positive human evidence, or a passkey within the reclaim window. |
A default worth starting from
| Resource class | Agent | Environment | Unknown |
|---|---|---|---|
| Balances, statements, transaction lists | mask | mask | allow |
| Personal / contact / KYC data | mask | mask | allow |
| Documents, exports, downloads | block | step_up | allow |
| Transfers, payments, orders | block | step_up | step_up |
| Deletes, permission and settings changes | block | step_up | step_up |
| Credentials, tokens, recovery data | block | block | step_up |
| Health / medical | block | mask | allow |
| Search over customer records | mask | mask | allow |
Masks, blocks and step-up
A mask is a second projection of the same record, built on the server: the page still renders, the value is gone. Keep the shape and the types.
const maskMoney = (b) => ({ ...b, amount: null, currency: b.currency, masked: true }); const maskIban = (v) => v.slice(0, 4) + ' •••• ' + v.slice(-4); const maskRows = (rows, cap = 20) => rows.slice(0, cap).map(maskRow);
- Mask the join, not only the field. A masked name beside an untouched e-mail or record id re-identifies in one step.
- Mask derived values too. Totals, chart series, CSV previews and
aria-labels carry the number you just removed. - Cap volume. A masked list of ten thousand rows is still a customer-base dump.
- Never mask an irreversible action. A half-executed transfer is worse than a refused one — those are
step_uporblock. - Don't leak through errors. A validation message that echoes the value defeats the mask.
Downloads
Protect the request that issues the link and bind the file to the decision, so a copied URL is dead:
app.post('/api/statement', nt.protect('report.export'), (req, res) => res.json({ url: `/files/${req.nt.token()}` })); app.get('/files/:token', async (req, res) => { const ok = await nt.engine.redeemToken(req.params.token, req.nt.session.id, 'report.export'); if (!ok) return res.sendStatus(403); streamStatement(res); });
Step-up and reclaim
A step_up answers 428 with a challenge; a block on an agent session answers 403 and, where a passkey is registered, offers the reclaim path. A successful WebAuthn user-verified assertion hands the session back to the person for a few minutes. Nothing in the page can grant that by itself.
Server API
| Package | Licence | What it is |
|---|---|---|
nanotarget | Apache-2.0 | What you install and import: the Express/Connect middleware (nanotarget/express), the browser SDK it serves, the CLI (npx nanotarget) and the proof verifier. |
nanotarget-engine | BUSL-1.1 | Its dependency, installed automatically: detection, policy, audit, proofs. Production use granted. You do not import it. |
import { nanotarget } from 'nanotarget/express'; const nt = await nanotarget(options);
| Option | |
|---|---|
secret | required, ≥ 32 bytes; signs tokens and derives the tenant. Keep it stable. |
policy | path to the JSON file, or the object. |
db | sqlite:./file, memory, or a libSQL URL for multi-instance and serverless. |
identify(req) | your login id, or null. Never a constant. |
apiKey | report decisions to the portal. Omit and nothing leaves your server. |
telemetryImmediate | send each report at once instead of every 3 s. Automatic on Vercel, AWS Lambda, Netlify and Azure Functions; set it on any other platform that suspends the process between requests. |
basePath, cookie, secure, tenant, respond, webauthnReclaim | defaults are sensible: /nanotarget, nt_sid, auto, default, true, true. |
| Method | |
|---|---|
nt.middleware() | Mount before your routes. Serves the page script and its API under basePath. |
nt.protect(resource, { respond }) | Decides, then answers 403/428 itself — or sets req.nt and calls next() when respond: false. |
nt.send(req, res, full, mask) | Sends full, or mask(full) when the decision says mask. |
req.nt | { decision, masked, blocked, stepUp, actor, reasonCodes, session, token(), proof }. proof is the signed record of this decision — server-side only. |
nt.reloadPolicy() | Re-read the policy file without a restart. |
nt.telemetry?.flush() | Deliver queued reports before exit. |
nt.proofBundle(sessionId) | Every signed decision for one session, with the key that verifies them — the file you hand an auditor. |
nt.proofFor(decisionId) · nt.verifyProof(jws) · nt.proofKeys() | One proof by decision id; check a proof; the public key set. |
Browser SDK
Served from your own origin at <basePath>/sdk.js. It reports observations and redacts sealed elements; it never decides anything.
| Member | |
|---|---|
NanoTarget.fetch(url, init) | Same signature as fetch. Use it for protected endpoints so the action's evidence reaches the server. |
NanoTarget.sessionHeaders() | For axios/ky interceptors when you cannot swap fetch. |
NanoTarget.onAssessment(fn) | Called when the session's state changes — update your own UI. |
NanoTarget.unseal(proof) | Reveals sealed elements. Accepts only a server-issued reclaim proof. |
data-nt-sensitive="full" | "masked" | Marks rendered values. full elements are redacted the moment an agent appears; the document event nt:sealed fires once. |
CSP: script-src 'self' and connect-src 'self' are enough.
HTTP endpoints
Mounted by nt.middleware() under basePath (default /nanotarget):
Protected responses carry X-NT-Decision and X-NT-Policy, and nt.send adds a small _nt summary to the body so your front end can explain itself.
Portal & telemetry
Create a key in the portal and pass it as apiKey. The middleware then reports each decision in the background: batched, off the request path, dropped rather than blocking if the portal is unreachable, and switched off for good if the key is rejected.
The portal answers the question nobody can answer today: what share of your signed-in sessions had an AI agent in them — plus decisions over time, which agent products were seen, which resources they reached for, and a live log.
What a report contains
| Sent | Never sent |
|---|---|
| A hash of the session id, the resource name, the decision, the actor, the session state, detected tool names, reason codes, enforcement mode, engine version, and the signed proof of the decision (the same fields, signed) | Request or response payloads, customer identities, e-mail addresses, IP addresses, URLs, page text, form values |
Decision proofs
Every decision is signed on your server the moment it is made, with a key derived from your secret. The proof states what was decided and why — resource, decision, actor, reason codes, policy and engine versions, whether data was delivered — and the decision's place in the tamper-evident audit chain. It carries a digest of the session, never the session id, and nothing the person typed or saw.
Your customers see nothing: no header, no field, no extra request. The proof travels with the telemetry report, the portal checks the signature on arrival, and each verified decision shows a ✓ in Activity. Export proofs downloads every signed decision in range — or click a ✓ for one session — as a single file an auditor can check without trusting us, you, or the database:
npx nanotarget verify-proof proofs.json --keys https://your-app.example/nanotarget/proof-keys
Auditor report, next to Export proofs, opens every signed decision in range as a printable page: how many verified, what was decided, who was acting, and the steps to check it independently. Save it as PDF and hand it to compliance together with the JSON file.
Proofs are standard compact JWS (EdDSA / Ed25519), so any JOSE library verifies them too. Pass --keys from your own site for an independent check; the keys inside a bundle prove it is consistent, your site proves where it came from.
People blocked
Activity opens with the number a bank asks first: how many people were blocked. It counts decisions the engine itself judged human that a rule still blocked or masked, plus gated decisions you graded wrong. It should stay at 0. Next to it: sessions judged human, and how often a person was asked to confirm with a passkey instead of being blocked.
Integration check
The Integration page shows four lights for the selected key, built only from what the key already reported: the server is reporting, pages send browser signals, signatures verify, and the policy is enforcing. Each warning names the fix. To test end to end, run npx nanotarget verify <your-app> <protected-path> from your own machine; the decision appears within seconds. NanoTarget never sends traffic to your site.
Weekly report
The last 7 days against the 7 before: sessions, the share with an AI agent, agents stopped, people blocked, passkey confirmations, which agent products are new on your site, what they reached for, and what changed in the agents themselves this month. Save it as PDF to forward to a security lead.
Policy builder
The Policy page lists the resources your middleware has reported and lets you pick the protection for each in plain words: open, masked for agents, block agents, block agents with a passkey when unsure, or a passkey for everyone. It writes the nanotarget.policy.json your server reads, in observe or enforce mode. Paste your current file to edit it. Nothing changes for your users until the server loads the new file.
Grading decisions
Every decision in Activity has two marks: right, or wrong. A gated decision marked wrong is a false stop (a person was masked or blocked); an allow marked wrong is a miss. The counts appear on the Activity page for the selected range. This is the only ground truth a detector gets from the field, and the reason to grade a few decisions a week.
Health
GET <basePath>/health on your server answers with the policy version, the reporter's state and any integration warning — most importantly when identify() returns the same value for many different visitors, which would merge them into one session. It returns 503 while a warning stands, so your monitoring notices.
Key lifecycle
A key can be rotated — the same key keeps its name, environment and history while a new secret is issued and the old one stops working at once — or revoked, which stops its servers reporting but leaves everything it already reported in place. A revoked key can then be deleted, and its decisions go with it. The decision log exports to CSV for whatever range is on screen.
Management API
A management key (nt_admin_…, created in the portal under Settings) administers an account over HTTP, so a coding agent or a CI job can set NanoTarget up without anyone opening the portal.
curl -H "Authorization: Bearer $NT_ADMIN" https://nanotarget-mvp.vercel.app/api/v1/manage/me
A management key can create and revoke project keys. Treat it like a password and revoke it when the job is done.
Rollout
- Observe. Ship with
"enforcement": "observe". Nothing is blocked; every decision is recorded with what would have happened. - Read the log for a week or two. The portal shows how much agent traffic you actually have and which resources it reaches for.
- Tighten the policy where the log says it matters, then flip one word to
enforce. - Give people the way back. Register passkeys so a blocked person can reclaim their own session instead of calling support.
Privacy & data
- The page script is first-party, served from your origin, and posts only to your origin.
- It reports metadata about how the session behaves — never page text, form values, key identities or the content of what is displayed.
- No cross-site identifiers, no third-party requests, no device fingerprinting for tracking purposes.
- Decisions and their evidence live in your database. Telemetry to the portal is opt-in per key and carries the fields listed above.
- Decisions are written to a hash-chained log, so an auditor can verify afterwards that the record was not edited.
For a security or legal review there is a one-page summary at /trust: what stays on your server, what leaves when you opt in, and what is never collected.
Limits & honest caveats
A security product that claims no limits is selling something. Ours:
- Server-to-server traffic is out of scope. API keys used outside a browser session carry no session evidence; they belong to your existing API security.
- Clients without the page script — mobile apps, partner integrations — arrive as
unknown. Decide what that branch should do. - A person can still relay data by hand. No session-layer control stops someone reading their screen aloud.
- This is an adversarial field. Agent products change; so do we. The engine is versioned, decisions record the version that made them, and the model is retrained against new evasion classes.
- Detection is evidence, not omniscience. When the engine does not know, it says
unknowninstead of guessing — and your policy decides what that deserves.
FAQ
- Why don't you publish exactly how detection works?
- Because the people it is designed to detect read documentation too. We publish the model — evidence classes, decision tiers, the rules that shape every answer — and the measured outcomes. The specific measurements inside each class stay in the product. Customers under NDA get the full technical description and can run their own evaluation against it.
- Does this block legitimate agents?
- Only where your policy says so. The point is control, not prohibition: many teams allow reads and gate exports and money movement. A verified, signed agent operator can be treated differently from an anonymous one.
- What happens if the page script is removed or fails?
- The session has no human evidence, so it is
unknown— never human. Your policy'sonUnknownbranch decides. Enforcement itself is unaffected: it lives on the server. - Does it slow requests down?
- The decision is a local lookup and an evaluation against stored evidence — no outbound call on the request path. Portal telemetry is queued and sent in the background.
- Where is the data stored?
- In your database:
node:sqliteon disk by default, or libSQL for multi-instance and serverless deployments. - What is the licence, and can we use it in production?
- Yes. The browser SDK, the middleware, the CLI and the proof verifier (the
nanotargetpackage) are Apache 2.0. The engine (nanotarget-engine) is Business Source License 1.1 with a production-use grant: run it in production, at any scale, to protect your own applications and the services you provide to your customers. The one use not granted is offering NanoTarget itself to third parties as a competing hosted or embedded product. Each version becomes Apache 2.0 four years after release. - Can we run it without any telemetry?
- Yes. Leave
apiKeyunset and nothing leaves your network.
Agent protocol
NanoTarget is designed to be installed by a coding agent. Hand your agent one link and one sentence — install this — and it follows a written protocol instead of guessing.
Read https://nanotarget-mvp.vercel.app/docs/INTEGRATION-AGENT.md and install this into my app.
What the agent is told to do
- Analyse. Scan the codebase as an AI browser agent holding a customer's signed-in session, and report what such an agent could reach.
- Propose. Turn that into an exposure map: which resources are sensitive, and what should happen to each one.
- Ask. Put the decisions that are yours — not the agent's — in front of you, one at a time.
- Implement. Wire the middleware, tag the pages, write the policy file and every mask function.
- Verify. Run
npx nanotarget verifyand report what it left open.
AGENTS.md.Let it run your account too
A management key lets the agent create the project key, read usage and rotate secrets without you opening the portal — see the Management API.