
Zero trust is a named identity on every request, not a product you buy and turn on.
If a handler still skips the session check because req.ip starts with 10. or 192.168., you have a LAN pass. The packet arrived on a private network, so the route treats the caller as staff. That is the opposite of the model.
The usual mistake is quoting NIST and then authorizing by security group, VPC CIDR, or X-Forwarded-For. Those are routing facts. They are not a login.
This page is the check that belongs in the handler: identify the caller, authorize the object, and keep a session you can delete. The sibling NIST page is the vocabulary.
OMB Memorandum M-22-09 is dated 26 January 2022. Civilian agencies had to meet the named goals by the end of Fiscal Year 2024. That year closed on 30 September 2024. I still open handlers that skip the session check when req.ip starts with 10. or 192.168.. The packet arrived on the LAN, so the route treats the caller as staff.
The control is a verifier in front of the handler, an allowlist of who may touch orderId, and a row you can revoke. Pair this with cookie sessions for the flags, JWT in Express when a peer must accept a blob, and IDOR for the object check after the actor is known.
What NIST actually asks of a handler
As of 22 August 2026, SP 800-207 on CSRC. The record is still the August 2020 final. Fifty-nine pages. Document history ends at 11 August 2020. There is no Rev 1. The abstract is the line this page implements: no implicit trust from physical or network location, and authentication plus authorization as discrete functions before a session to a resource starts.
For an app that is one sentence. The route is the resource. The cookie or bearer is the subject claim. The LAN is not a claim. Tenet 2 in section 2.1 says communication from inside a legacy perimeter must meet the same bar as traffic from anywhere else. Tenet 3 says access to one resource does not grant another. Tenet 6 says the check is dynamic and happens before access is allowed.
That maps to three functions you can name in code:
requireActorreads a session or a verified JWT and writesreq.actor. Missing or stale is 401.authorizeOrderloadsorderIdand asks whetherreq.actor.userIdmay read or write it. No is 403.denyLanBypassis the absence of anif (isPrivateIp(req.ip)) next()branch. There is no skip.
A vendor ZTNA box in front of the app can still be useful. It does not replace those three. If the box is down or a sibling pod calls you on the overlay, the handler is the last PEP you own. The NIST architecture page names PE, PA, and PEP. Here, requireActor plus authorizeOrder is the PEP. The session store is the PA. The policy in canReadOrder is the PE.
Identify the caller on every request
express-session 1.19.0 published on 22 January 2026. npm page for that date. The default cookie name is still connect.sid. Name it __Host-session. httpOnly, secure, sameSite: "lax", path: "/", no Domain. Redis or Postgres holds the row. MemoryStore is the development default. The readme says do not use it in production.
jose 6.2.10 published on 21 August 2026. Use it when a second host must accept a signed blob without your store. Pin algorithms, iss, aud, and exp. A JWT is not a free upgrade. Logout is not a feature of RFC 7519. If you can delete a row, prefer the row. The JWT guide is the verifier. This page is the rule that the verifier runs on every mutating route and every read of a private object.
import { jwtVerify } from "jose";
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
const ISSUER = process.env.ISS;
const AUDIENCE = process.env.AUD;
const key = new TextEncoder().encode(process.env.JWT_HS256);
async function actorFromBearer(req) {
const header = req.get("authorization") || "";
if (!header.startsWith("Bearer ")) return null;
const token = header.slice(7);
const { payload } = await jwtVerify(token, key, {
algorithms: ["HS256"],
issuer: ISSUER,
audience: AUDIENCE,
});
if (typeof payload.sub !== "string") return null;
return { userId: payload.sub, via: "jwt" };
}
function actorFromSession(req) {
const userId = req.session && req.session.userId;
if (!userId) return null;
return { userId, via: "session" };
}
async function requireActor(req, res, next) {
try {
const actor = actorFromSession(req) || await actorFromBearer(req);
if (!actor) {
res.status(401).send("Unauthorized");
return;
}
req.actor = actor;
next();
} catch (err) {
res.status(401).send("Unauthorized");
}
}
app.use("/api", requireActor);
Public marketing GET can skip requireActor. Anything that reads a customer row cannot. Mount the check at /api, not inside two of seven routers. A healthz route lives outside that mount. An admin route does not get a second, looser mount because it sits on an internal listener.
Identifiers stay actor, userId, orderId, and sid. If you add a service-to-service call, the caller is still a subject: a workload identity, an mTLS SPIFFE id, or a short-lived token with a sub you minted. A shared X-Internal: 1 header is a password you published to every hop.
A private IP is not a login
RFC 1918 space is 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. IPv6 unique local is fc00::/7. Those ranges describe routing. They do not describe a person. Kubernetes pod IPs rotate. A stolen laptop on Wi-Fi is still 10.something. A compromised function in the same VPC is still 10.something. Tenet 2 is the sentence that kills the bypass.
// BAD: LAN as identity. Do not ship this.
// if (isPrivateIp(req.ip)) return next();
function isPrivateIp(ip) {
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| ip.startsWith("172.16.");
}
// FIX: address is telemetry, never a pass
function logPeer(req) {
req.log.info({
userId: req.actor && req.actor.userId,
ip: req.ip,
via: req.actor && req.actor.via,
}, "api");
}
A private listener can still exist. Bind admin to 127.0.0.1 or to a mesh identity. Then run the same requireActor. The bind narrows who can open a socket. It does not name the user. mTLS between services is a strong bind. After the handshake you still authorize orderId.
CISA Zero Trust Maturity Model 2.0 is dated April 2023. PDF. The Applications and Workloads pillar treats each app as reachable and asks for strong application-layer checks. A flat allow from the corporate CIDR is the Traditional column, not Advanced.
Authorize the object, not the subnet
Knowing userId is not permission to read every row. That gap is CWE-639 and the IDOR page. Zero trust tenet 3 is the same idea in architecture words: a grant for one resource is not a grant for the next. After requireActor, load the object, then ask a function you can test.
async function canReadOrder(actor, order) {
if (!order) return false;
if (order.ownerId === actor.userId) return true;
if (actor.role === "support" && order.orgId === actor.orgId) return true;
return false;
}
async function authorizeOrder(req, res, next) {
const orderId = req.params.orderId;
const order = await findOrderById(orderId);
if (!order || !(await canReadOrder(req.actor, order))) {
res.status(404).send("Not found");
return;
}
req.order = order;
next();
}
app.get("/api/orders/:orderId", authorizeOrder, (req, res) => {
res.json({ id: req.order.id, total: req.order.total });
});
Return 404 on a miss or a forbid so you do not confirm ids to a scanner. Support staff get a named role and an orgId match, not a global role === "admin". A service account that invoices gets via: "jwt" and a scope list, not the human admin role.
CLIENT | cookie __Host-session=sid | or Authorization: Bearer... v PEP requireActor missing actor -> 401 LAN / RFC1918 is logged, not a pass | v PE canReadOrder(actor, order) ownerId or org-scoped support else 404 | v PA session row in Redis delete the row, sid is junk JWT only if a peer cannot see Redis
Service-to-service is the same shape. The billing worker presents a workload identity. requireActor maps that id to { userId: "svc-billing", via: "mtls" }. canReadOrder allows that subject on POST /api/orders/:orderId/invoice and nowhere else. A shared cluster role that can read every secret is the LAN pass in IAM clothing.
A proof you can delete
A session row is a kill switch you already have. req.session.regenerate after login. Destroy on logout, on password change, and on a privilege change. Idle timeout is a sliding lastSeen. Absolute timeout is createdAt plus a number you can explain. Twelve hours is a starting absolute cap for a first-party browser app, not a standard.
A JWT waits until a peer cannot see your store. Then keep exp short and keep a jti denylist if you need a panic button. That denylist is a session store with extra steps. Do not put the blob in localStorage. An HttpOnly cookie that holds a JWT is still a cookie. You inherited the browser attaching it. Read the JWT page for the verifier, not for a reason to abandon the row.
MFA belongs on the login that mints sid, not as a comment on the architecture slide. SP 800-207 tenet 6 names ICAM and MFA for access to some or all resources. Name the factor: WebAuthn if the browser ships it, TOTP if you must. A VPN stand-up is not a factor for the app.
| Signal | Use it for | Never use it as |
|---|---|---|
| Session row | Browser actor you can revoke | Proof that the LAN is safe |
| Short JWT | A peer with no Redis | A week-long login |
| mTLS / SPIFFE | Workload identity | Object authorization |
| Source IP | Rate limit, audit | req.actor |
| Security group | Who may open a port | Who may read orderId |
Prove the path on your own app
You are proving your handler. You are not walking someone else’s network.
- Log in on your own origin. DevTools, Application, Cookies. Expect
__Host-session. - Call
GET /api/orders/ORDER_YOU_OWN. Expect 200 and your totals. - Call the same URL with the cookie deleted. Expect 401.
- Call
GET /api/orders/SOMEONE_ELSES_IDwith your cookie. Expect 404, not 403 with a body that confirms the id. - From a shell on the same VPC, curl the route with no cookie and no bearer. Expect 401. A 200 means the LAN pass is still in the file.
curl -sS -D - -o /dev/null \
"https://your-app.example/api/orders/ORDER_YOU_OWN"
# Expect: HTTP/2 401
curl -sS -D - -o /tmp/order.json \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
"https://your-app.example/api/orders/ORDER_YOU_OWN"
# Expect: HTTP/2 200 and your order
rg -n "isPrivateIp|x-internal|trustProxy|req\\.ip" --glob '!node_modules'
Grep is the five-minute audit. isPrivateIp, x-internal, X-Forwarded-For used as a gate, app.enable("trust proxy") plus a skip: those are the LAN pass. Fix the skip. Then keep the IDOR test in CI so a new router cannot ship without authorizeOrder.
Questions we keep getting
Does a service mesh replace requireActor?
A mesh can demand mTLS and a workload identity before the socket opens. That is a strong bind. The handler still names req.actor and still runs canReadOrder. A sidecar outage or a mislabeled peer should fail closed, not open a raw port to the process.
Can I skip auth on GET if I only mutate on POST?
A private GET is still a resource. Invoice PDFs, export CSVs, and admin lists leak on GET. Run requireActor on those reads. Public marketing pages stay outside the /api mount.
Is a company VPN enough for an admin UI?
No. The VPN is a path. Put the admin UI on the same requireActor plus a named role. Prefer a separate origin and a Host-prefixed session. Do not add if (isPrivateIp).



