Get listed

Zero trust: authenticate every request, no LAN pass

A row of stamped teal doors and a torn coral LAN pass on the floor.

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:

  • requireActor reads a session or a verified JWT and writes req.actor. Missing or stale is 401.
  • authorizeOrder loads orderId and asks whether req.actor.userId may read or write it. No is 403.
  • denyLanBypass is the absence of an if (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.

The LAN hop is a line on the wire. It never becomes req.actor.
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.

SignalUse it forNever use it as
Session rowBrowser actor you can revokeProof that the LAN is safe
Short JWTA peer with no RedisA week-long login
mTLS / SPIFFEWorkload identityObject authorization
Source IPRate limit, auditreq.actor
Security groupWho may open a portWho may read orderId

Prove the path on your own app

You are proving your handler. You are not walking someone else’s network.

  1. Log in on your own origin. DevTools, Application, Cookies. Expect __Host-session.
  2. Call GET /api/orders/ORDER_YOU_OWN. Expect 200 and your totals.
  3. Call the same URL with the cookie deleted. Expect 401.
  4. Call GET /api/orders/SOMEONE_ELSES_ID with your cookie. Expect 404, not 403 with a body that confirms the id.
  5. 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).