Zero-trust security: secure one access path end to end

A signed gateway assertion still needs an application permission check. Reproduce the allowed request, cross-customer denial, and revocation of an already issued assertion.
An admitted user can still request the wrong ticket
The local support-console fixture admits Alice through a gateway and returns ticket 42 from customer A. Changing the request to ticket 77, owned by customer B, returns 404 with no ticket contents. Both requests carry a valid gateway signature. The difference is the origin’s resource authorization, not whether Alice passed the gateway.
NIST SP 800-207 rejects implicit trust based solely on network location or asset ownership. That principle makes the application boundary worth testing even behind an identity gateway. OWASP’s authorization guidance recommends denying access by default and evaluating permissions on every request.
This example runs two real HTTP servers on container loopback, using synthetic users and an in-memory directory. It exercises the request path from gateway admission to ticket response. It does not implement an identity provider, device attestation, production TLS, network isolation, or a complete zero-trust architecture.
Verify the assertion before using its subject
The gateway accepts a randomly generated test session identifier, looks up its current user, and requires support-group membership. It signs a short-lived assertion with an ephemeral RSA private key. The origin receives only the public verification key. These sessions are test setup, not a login implementation. Production authentication must establish them through a trusted integration.
The origin calls this complete module before dispatching a ticket request. It uses jose 6.2.12 on Node 22.20.0. The configured key and RS256 allowlist determine acceptable signatures. Issuer, audience and token type restrict which assertions belong on this path. The subject and session identifier must also be nonempty strings.
The fixture requires issued-at and expiry claims and limits assertion age to thirty seconds. That interval is an example policy choice, not a recommended universal lifetime. RFC 8725 discusses algorithm verification, issuer and audience validation, and explicit token typing. Checking a signature alone does not establish those application expectations.
import { jwtVerify } from "jose";
export async function verifyAssertion(token, publicKey, now = new Date()) {
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "urn:example:support-gateway",
audience: "urn:example:support-origin",
typ: "support-assertion+jwt",
requiredClaims: ["sub", "sid", "iat", "exp"],
maxTokenAge: "30s",
currentDate: now,
});
if (typeof payload.sub !== "string" || !payload.sub ||
typeof payload.sid !== "string" || !payload.sid) {
throw new Error("Invalid identity claims");
}
return { subject: payload.sub, sessionId: payload.sid };
}
Keep current permissions outside the signed snapshot
The assertion carries identity, not a permanent grant to every ticket. This second module resolves the session and current user from the directory, then checks the ticket’s customer. A missing, disabled or mismatched session cannot become permission. The origin returns the same 404 body for an unauthorized ticket and an unknown ticket. The fixture does not establish constant-time responses.
In the revocation test, an assertion first reads ticket 42 successfully. The harness removes Alice’s support membership without changing the assertion or advancing the clock. Replaying it at the origin returns 404. Separate cases revoke the session and remove the customer assignment. Both deny the same previously valid assertion.
I recommend this live lookup when the resource needs permission changes to affect the next request. The cost is a directory dependency on that path. Here an injected directory outage returns 503 without ticket data. A cached or replicated production directory would introduce freshness and availability decisions that this single-process fixture cannot measure.
export async function authorizeTicket(identity, ticket, directory) {
// Directory failures propagate; callers must not turn them into permission.
const session = await directory.session(identity.sessionId);
if (!session?.active || session.subject !== identity.subject) return false;
const user = await directory.user(identity.subject);
if (!user?.active || !user.groups.includes("support")) return false;
return user.customerIds.includes(ticket.customerId);
}
A bearer assertion does not close the origin network path
Direct origin requests without an assertion return 401, including a request that forges user and group headers. A token signed by another key, a token for another audience, and an expired token are also rejected. The gateway constructs a fresh upstream header set, so a caller’s injected assertion is replaced rather than forwarded.
There is a deliberately successful direct-origin case too. The test harness possesses a valid assertion and can replay it while its session and permissions remain valid. The origin accepts it. That is the bearer-token boundary: this application verification does not prove the caller traversed the gateway on that request.
I recommend treating origin reachability as a separate deployment check. Restrict gateway-to-origin access using the chosen platform’s supported controls, protect the transport, and test alternate hostnames and ports from outside the permitted path. A private address alone is not evidence. Do not claim the bypass is closed until that deployed test fails as intended.
The fixture also rejects POST requests and exposes no export route. Those checks cover the implemented routes only. A real console needs the same authorization review for its downloads, background clients and administrative paths. Its emergency access procedure needs separate controls and an owner.
Reproduce the decisions, then break the controls
The source bundle contains both HTTP servers, these exact modules, the synthetic directory, and the test runner. From its extracted root, run python3 frontend/scripts/verify-zero-trust.py with Python 3 and Docker available. The image build downloads locked jose dependencies. Test containers then run without external networking, as an unprivileged user, with a read-only filesystem and resource limits. Keys and session values exist only in memory and are not included in the JSON output.
The normal run checks thirty HTTP responses, including their status, exact JSON body and no-store cache header. Four deliberate defects must fail: decoding without signature verification, allowing every customer, skipping current group membership, and ignoring session revocation. The runner checks the expected assertion failure for each defect so an unrelated crash cannot count as evidence.
The result is a reproducible authorization example, not a penetration test or a deployment template. Both servers share one process and directory. There are no distributed updates or concurrent permission changes. Key distribution and rotation, session creation and expiry, browser cookies and CSRF, device posture, operational audit logging, and emergency access remain outside this fixture. Keep those omissions in the deployment review rather than treating thirty passing requests as a security certificate.
Reproduce the examples
Two local HTTP servers, exact verification and authorization modules, thirty request checks and four deliberate defects. Includes the pinned dependency lock and reproduction instructions.
Download the source and tests (.zip)
