Verification of World App attested-key canonical requests, as Rust crates and as a sidecar container that sits in front of a service.
A World App request is signed by a device key that the Attestation Gateway attested. The
gateway's JWT (Integrity-Token) carries that key as cnf.jwk. The app signs an RFC 9421
signature base covering the method, scheme, authority, path, query, body digest and the token
itself; a verifier rebuilds the same base from the request it received and checks the signature
with the attested key. The wire format is the TFH RFC 9421 Integrity Request Signing Profile.
Verification proves that an attested app on an attested device signed this exact request. It does not identify a user, a PCP owner or a relying party, and it authorizes nothing. See Authorized Principal for that layer.
| Crate | Use it to |
|---|---|
attested-request |
Sign and verify canonical requests without any transport. Types, Signature-Input parsing and canonical serialization, the signature base, Attestation Gateway token verification (ES256 only), iOS App Attest and Android signature checks, a Signer trait for clients, an optional replay guard. The remote-jwks feature adds a cached HTTPS JWKS; test-util adds software signers and a fake gateway. |
attested-request-tower |
Verify in-process, in any tower or axum service. AttestedRequestLayer puts a VerifiedAttestedKeyContext in the request extensions; the axum feature adds the AttestedKey extractor. |
attested-proxy |
Verify in a sidecar, so the service itself needs no attestation code. |
A Rust service can embed the tower layer instead of running the sidecar; the sidecar is built on that same layer.
let app = axum::Router::new()
.route("/v1/config", axum::routing::post(handler))
.route_layer(AttestedRequestLayer::new(Arc::new(verifier)));
async fn handler(AttestedKey(context): AttestedKey) -> String {
format!("{} device {}", context.device.platform, context.device.key.thumbprint())
}The sidecar is the service's only ingress. For each request it:
- forwards it unverified if its path is listed in
ATTESTED_PROXY_UNPROTECTED_PATHS(exact match, meant for the load balancer's health check); - otherwise reads the body under a size limit and deadline, and verifies the request, answering
{"error": "<reason>"}with the reason's status when verification fails; - forwards the verified request to the one configured upstream. Clients never choose the destination. WebSocket upgrades are verified on the handshake and then tunnelled byte for byte.
Everything that can fail does so closed: an unreachable JWKS, an unreachable replay store and an unreadable body all refuse the request.
| Header | Value |
|---|---|
x-attested-platform |
ios or android |
x-attested-key-thumbprint |
RFC 7638 thumbprint of the device key: a stable per-device identifier, for example to rate limit on |
x-attested-request-binding |
base64(SHA-256(signature base)) of this exact request |
Client-sent copies of these headers are removed on every path, and the Integrity-Token,
Signature-Input and Signature headers are not forwarded. The upstream can trust these headers
only because it is reachable solely through the sidecar, so keep its port off the Service and the
load balancer.
Every flag has an ATTESTED_PROXY_* environment variable; attested-proxy --help lists them.
| Variable | Default | |
|---|---|---|
ATTESTED_PROXY_UPSTREAM |
required | The sibling container, http://127.0.0.1:8000. |
ATTESTED_PROXY_AUTHORITY |
required | The host clients dial, as signed in @authority. |
ATTESTED_PROXY_SCHEME |
https |
As signed in @scheme. wss:// clients sign https. |
ATTESTED_PROXY_AUDIENCES |
required | Accepted token audiences, comma-separated. |
ATTESTED_PROXY_ISSUERS |
required | Comma-separated issuer=https://... or issuer=file:///... JWKS sources. |
ATTESTED_PROXY_UNPROTECTED_PATHS |
none | Exact paths forwarded without verification. |
ATTESTED_PROXY_LISTEN |
0.0.0.0:8080 |
Proxy listener. |
ATTESTED_PROXY_ADMIN_LISTEN |
0.0.0.0:8081 |
/health (liveness) and /ready (readiness). |
ATTESTED_PROXY_MAX_AGE_SECS |
300 |
Oldest accepted created. |
ATTESTED_PROXY_MAX_FUTURE_SKEW_SECS |
60 |
Furthest-ahead accepted created. |
ATTESTED_PROXY_MAX_BODY_BYTES |
1048576 |
Largest body verified. |
ATTESTED_PROXY_BODY_READ_TIMEOUT_SECS |
10 |
Deadline for the request body. |
ATTESTED_PROXY_HEADER_READ_TIMEOUT_SECS |
10 |
Deadline for the request headers. |
ATTESTED_PROXY_UPSTREAM_CONNECT_TIMEOUT_MS |
2000 |
Deadline for connecting upstream. |
ATTESTED_PROXY_UPSTREAM_RESPONSE_TIMEOUT_SECS |
60 |
Deadline for upstream response headers. Open tunnels have none; the upstream owns their idle timeout. |
ATTESTED_PROXY_MAX_CONNECTIONS |
1024 |
Connections and tunnels together. Beyond it, connections wait in the listen backlog. |
ATTESTED_PROXY_SHUTDOWN_GRACE_SECS |
45 |
How long in-flight work may finish after SIGTERM. |
ATTESTED_PROXY_REPLAY_REDIS_URL |
none | Enables replay tracking (redis:// or rediss://). |
ATTESTED_PROXY_REPLAY_TIMEOUT_MS |
250 |
Deadline for one replay-store command. |
For example:
ATTESTED_PROXY_ISSUERS='attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json,ci.attestation.worldcoin.dev=file:///etc/attested-proxy/ci/jwks.json'The issuer is matched exactly against the token's iss. Invalid or empty entries, duplicate
issuers, unsupported URL schemes and unreadable or invalid local files fail startup. File URLs
must be local, with no query or fragment; percent-encoded paths are decoded. Encode commas
inside URLs as %2C. This setting replaces the previous issuer and JWKS environment variables.
All issuers use the configured audiences and the same ES256 token and request-signature checks;
CI requests still need the device private key matching the token's cnf.jwk.
HTTPS sources refresh independently. Local public JWKS files are loaded once at startup, must
be at most 64 KiB and must contain usable ES256 keys. Keep token-signing private keys on the
client; mount only public JWKS in the proxy. To rotate local keys, publish overlapping old and
new kids, roll the proxy pods, switch the signer, then remove the old key and roll again after
old tokens expire.
Telemetry is configured by telemetry-batteries (TELEMETRY_PRESET=datadog,
TELEMETRY_SERVICE_NAME, TELEMETRY_METRICS_BACKEND=statsd, …), as in Flamingo.
In a common-app chart, add the sidecar and move traffic to its port. For Flamingo:
service:
port: 8080
httpRoute:
port: 8080
targetGroupConfigurations:
- defaultConfiguration:
healthCheckConfig:
healthCheckPath: /ready # unprotected, forwarded to Flamingo's enclave-aware /ready
healthCheckPort: "8080"
sidecars:
- name: attested-proxy
image: <attested-proxy image>
env:
- { name: ATTESTED_PROXY_UPSTREAM, value: "http://127.0.0.1:8000" }
- { name: ATTESTED_PROXY_AUTHORITY, value: flamingo-verifier.toolsforhumanity.com }
- { name: ATTESTED_PROXY_AUDIENCES, value: <flamingo audience> }
- { name: ATTESTED_PROXY_ISSUERS, value: "attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json" }
- { name: ATTESTED_PROXY_UNPROTECTED_PATHS, value: "/health,/ready" }
ports:
- { name: proxy, containerPort: 8080 }
livenessProbe: { httpGet: { path: /health, port: 8081 } }
readinessProbe: { httpGet: { path: /ready, port: 8081 } }For CI, extend the issuer list and add a read-only volume mount to the sidecar, and the volume to the pod spec (or the chart's equivalent fields):
# Sidecar fields:
env:
- name: ATTESTED_PROXY_ISSUERS
value: "attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json,ci.attestation.worldcoin.dev=file:///etc/attested-proxy/ci/jwks.json"
volumeMounts:
- { name: ci-jwks, mountPath: /etc/attested-proxy/ci, readOnly: true }
# Pod field:
volumes:
- name: ci-jwks
secret:
secretName: attested-proxy-ci-public-jwksThe Secret's jwks.json entry contains public keys only.
The pod is ready only when both containers are: the service's own probe as before, and the sidecar's once any configured key source is usable. A successfully loaded local JWKS remains usable until restart. An unavailable remote source returns 503 for affected requests; other issuers can continue serving traffic. The sidecar stops reporting ready as soon as it receives SIGTERM, then drains.
| Metric | |
|---|---|
attested_request.verified |
Verified requests, by platform. |
attested_request.rejected |
Refused requests, by reason, platform and status. |
attested_request.verify.duration |
Verification latency, by outcome. |
attested_proxy.upstream.errors |
Upstream failures, by kind (unavailable, timeout). |
attested_proxy.tunnels.opened, .failed, .active |
WebSocket tunnels. |
attested_proxy.connections.active |
Open client connections. |
attested_proxy.jwks.age_seconds |
Age of the cached JWKS; alert well before it reaches six hours, when verification starts failing. |
attested_proxy.jwks.usable |
1 while the cached JWKS can verify tokens, 0 before the first fetch and once it is too stale. Alert on 0: age_seconds reports nothing until a fetch succeeds. |
attested_proxy.jwks.fetch_failures |
Failed JWKS fetches across startup, periodic and request-triggered refreshes (once per shared fetch), by class (the HTTP status code, timeout, request, invalid, ...). |
The attested_proxy.jwks.* metrics describe HTTPS sources and carry an issuer label containing
the configured issuer identifier. Local sources do not fetch or refresh.
One INFO event at startup reports the accepted issuer count, HTTPS/file source counts and loaded file-key count. It confirms configuration loading; remote keys may not have been fetched yet.
Client rejections are counted, not logged. Failures of our own dependencies (JWKS, replay store,
upstream) are logged at warn. Tokens, signatures and bodies are never logged.
created bounds how long a captured request can be replayed (five minutes by default). Replay
tracking additionally refuses a second use inside that window, at the cost of a store on the
request path that must be up for any request to succeed. It is off unless
ATTESTED_PROXY_REPLAY_REDIS_URL is set. A route whose effect can be repeated needs it; a route
that only opens a session, such as Flamingo's WebSocket handshake, usually does not.
The replay store must use maxmemory-policy noeviction: evicting an unexpired claim allows that
request to be replayed. Provision enough memory for the acceptance window; when full, writes must
fail so verification fails closed. Losing claims during a restart or failover also permits replay
until those requests expire; configure persistence and recovery accordingly.
@signature-paramsis serialized, not copied. RFC 9421 §3.2 step 7 builds it by serializing the parsedSignature-Inputmember. Some existing verifiers copy the raw substring afterintegrity=instead. Current signers emit the canonical form, so both approaches accept their signatures today; only this one also accepts equivalent spellings, such as extra whitespace, that Structured Fields allows.@schemeand@authoritycome from configuration, never from theHostheader.- Reject reasons and statuses match go-sonic's canonical request middleware, so clients see one taxonomy across services.
- iOS assertions are checked against
cnf.jwkonly. Their rpId and counter are not checked: App Attest keys are app-scoped, the gateway verified the app when it attested the key, and freshness comes fromcreated.
test-vectors/ holds vectors for any implementation of the profile:
signature-base.json: signature bases and their hashes, including equivalent whitespace, parameter order and derived components. Generated bygenerate_signature_base.py, independently of the Rust code.verification.json: signed requests and the verdict a verifier must reach, from a fixed JWKS and clock. Generated bycargo run -p attested-request --example generate-vectors --features test-util.
CI fails if either file differs from what its generator produces.
nix develop # or install the toolchain in rust-toolchain.toml
cargo test --workspace --all-features
nix flake check # clippy, tests, fmt and the package, as in CI
docker build .The sidecar image is published to GHCR as ghcr.io/worldcoin/attested-proxy:
- every push to
mainpublishes:latestand:sha-<sha>; - a pushed
attested-proxy/vX.Y.Ztag (or a manual run of the release image workflow) publishes:vX.Y.Zand opens a draft GitHub release recording the image digest; - pull requests that touch the build inputs build without pushing.
Deploy by digest (ghcr.io/worldcoin/attested-proxy@sha256:…), not by tag.
gh workflow run release-image.yml -f ref=main -f version=0.1.0 -f dry_run=false