Turn a network-verified caller identity into a short-lived, asymmetrically signed JWT.
A private network tells you who a caller is. Most applications cannot read
that, so the usual answer is a proxy that injects an identity header. That
works only while nothing else can reach the port, because the header is a
claim, not proof. tsjwt replaces the claim with a signature. Reaching the
port then proves nothing; only a token signed by a key the signer holds does.
The trust boundary collapses from a whole network range to one private key.
This is the identity-aware proxy pattern, the same shape as Cloudflare
Access Cf-Access-Jwt-Assertion and Google IAP X-Goog-Iap-Jwt-Assertion.
sequenceDiagram
autonumber
participant C as Caller<br/>(tailnet device)
participant G as tsjwt gateway<br/>(signer / data plane)
participant T as Tailnet<br/>(trust anchor)
participant B as Backend
C->>G: request, over WireGuard
G->>T: WhoIs(peer)?
T-->>G: the verified identity, its tenants and roles
Note over G: mint a short-lived ES256 JWT,<br/>scoped to this backend audience,<br/>strip any inbound copy of the header
G->>B: request + X-Tailnet-Jwt-Assertion
Note over B: verify the signature against the<br/>published key set, public keys only
B-->>C: response
Reaching the port proves nothing; only a token signed by the gateway's private
key does — and the backend needs only public keys to check it. Identity comes
from the network (WhoIs), not from anything the caller sends.
It is not tied to any one organisation. Tenancy, role names and group names are all supplied by the caller. The only shipped identity source reads a Tailscale tailnet, and it lives in a separate module so that nothing else depends on it.
See ARCHITECTURE.md for how the parts fit together and how this compares with tsidp, Pomerium and a shared secret. See SECURITY.md for the threat model.
caller ──WireGuard──▶ signer ──X-Tailnet-Jwt-Assertion──▶ backend
│ │
WhoIs() verify against
(the trust anchor) /.well-known/jwks.json
- The signer is a node on the private network. On each connection it asks the network who the peer is. A caller cannot influence the answer: it can only choose whether to connect.
- It resolves that identity to the tenants it may act for, and the roles it holds in each.
- It mints an
ES256JWT, short-lived, with a uniquejti, and injects it. Any inbound copy of the header is stripped first. - The backend verifies the signature against a published key set, selecting
the key by
kid. It holds only public keys, so a compromised backend cannot mint for any other backend.
| Package | Depends on | Purpose |
|---|---|---|
tsjwt |
stdlib | Identity, Tenant, Grant, Claims, the two interfaces |
tsjwt/jwt |
stdlib | ES256 sign and verify, one algorithm only |
tsjwt/keys |
stdlib | Rotating key set, RFC 7638 key ids, JWKS |
tsjwt/signer |
stdlib | Mints assertions; serves JWKS |
tsjwt/verifier |
stdlib | Checks assertions; remote JWKS; middleware; replay guard |
tsjwt/proxy |
stdlib | The identity-aware reverse proxy |
tsjwt/k8sstore |
stdlib | Shared key set in a Kubernetes ConfigMap |
tsjwt/cmd/tsjwt-echo |
stdlib | Reference backend: verifies and echoes what it read |
tsjwt/tsnetid |
tailscale.com | Tailnet identity source, and the tsjwtd daemon |
The core module imports only the standard library. tsnetid is a separate Go
module, so a service that merely verifies tokens takes on no dependency.
One algorithm. jwt implements ES256 and refuses everything else, in
both the parser and the verifier. A library that accepts many algorithms has
to be configured carefully to avoid algorithm confusion, where a public key
published for verification is accepted as an HMAC secret. Supporting one
algorithm removes that bug class instead of documenting it.
Key ids are derived, never assigned. A kid is the RFC 7638 thumbprint
of the key itself, so a key id can never be reused for a different key.
Rotation has an overlap window, and it is checked. A retired key keeps
verifying for the window, then stops. tsjwtd refuses to start if the
overlap does not exceed the token lifetime, because a token minted just
before a rotation would otherwise fail before it expired.
Lifetime is capped twice. The signer clamps to MaxTTL. The verifier
independently rejects a token whose exp is too far from its iat, whatever
the signature says, which bounds the damage from a signer minting long
tokens.
Audience is mandatory. Both sides require it. It is what stops a token minted for one backend being replayed at another.
Tagged nodes are refused by default. A tagged node is a machine, not a person, and has no user identity to assert. Turn it on deliberately.
The proxy strips before it does anything else. A caller-supplied assertion header is removed unconditionally, before any path that could return early, so no request carries one through.
Errors do not leak. Every rejection wraps ErrInvalidToken; the reason
goes to the log, and the caller gets 401.
TenantResolver is the extension point. It maps a verified identity to a
Grant: the tenants it may act for, and the roles held in each.
type TenantResolver interface {
Resolve(Identity) (Grant, error)
}A resolver must fail closed. tsjwtd ships a file-driven implementation so a
deployment changes tenancy by changing a file, not the binary:
{
"tenants": [
{"id": "acme", "default": true,
"groups": {"group:admin": "admin", "group:eng": "editor"}},
{"id": "globex",
"groups": {"group:globex-admin": "admin"}}
]
}A caller may ask for a tenant, with X-Tsjwt-Tenant or ?tenant=. The
signer decides: a tenant the identity does not hold is refused with 403.
An identity is granted a tenant only when one of its groups maps into that
tenant. A top-level "defaultRole" grants that role to an identity matching no
group, but only in a single-tenant policy — in a multi-tenant policy it is
ignored, because granting an unmatched identity a role in every tenant would
break the isolation the line above promises.
Verify in a backend:
v, _ := verifier.New(verifier.Config{
Issuer: "https://tsjwt.example.ts.net",
Audience: "grafana",
Keys: remoteKeys, // or verifier.LocalKeys{Set: set}
Replay: verifier.NewMemoryReplayGuard(),
})
http.Handle("/", v.Middleware("", app))
// inside app
claims, ok := tsjwt.ClaimsFrom(r.Context())
if !ok || !claims.HasRole("editor") { ... }Run the proxy:
tsjwtd \
-hostname tsjwt \
-upstream http://backend.internal \
-audience backend \
-tenants /etc/tsjwt/tenants.json \
-ttl 5m -rotate 24h -overlap 1h
TS_AUTHKEY is read from the environment, never a flag, so it does not
appear in the process table.
| Claim | Meaning |
|---|---|
iss sub aud iat nbf exp jti |
RFC 7519 |
email name node |
Informational. Never authorize on these. |
tenant |
The tenant this token acts for. Enforce isolation on this. |
tenants |
Every tenant the identity may act for. |
roles |
Roles held within tenant. Authorize on these. |
groups |
Raw identity-source groups. Informational: not tenant-scoped. |
sub is the identity source's stable id, not the login name, because a login
name can be reassigned.
- Device theft inherits identity, under this scheme and every other.
- Replay protection is per-process by default. A horizontally scaled
backend needs a shared store behind
ReplayGuard. - Roles are coarse. Per-action authorization stays an application concern.
- Revocation is expiry. There is no deny list. The lifetime cap is the control, which is why it is short and enforced on both sides.
One signer can front many backends. A route picks the backend and the audience, together, because a token minted for one backend must not work at another:
router, _ := proxy.NewRouter(
proxy.Route{Name: "grafana", Hostnames: []string{"grafana.example.ts.net"},
Upstream: grafanaURL, Audience: "grafana"},
proxy.Route{Name: "api-v2", Hostnames: []string{"app.example.ts.net"},
PathPrefix: "/api/v2", Upstream: apiURL, Audience: "api"},
)
px, _ := proxy.New(proxy.Config{Router: router, Signer: sg})Matching follows Gateway API: the most specific hostname wins, then the
longest path prefix, then declaration order. A request that matches no route
gets 404 and no token is minted, because there is no audience to mint
one for.
Route.Tenant pins a route to one tenant regardless of what the caller asks
for.
tsjwt implements Gateway API, so adopting it is an HTTPRoute rather than a
deployment.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: edge}
spec:
gatewayClassName: tsjwt
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
# The certificate comes from the private network, for the node's own
# name, so there is no Secret to reference.
options: {tsjwt.dev/certificate: tailnet}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: app}
spec:
parentRefs: [{name: edge}]
rules:
- matches: [{path: {type: PathPrefix, value: /}}]
backendRefs: [{name: app, port: 8080}]The controller provisions a data plane per Gateway, renders the attached
routes into a ConfigMap, and reports status. The audience is derived from
the backend Service as <service>.<namespace>, so two services behind one
Gateway can never share one.
Two halves, deliberately:
| Job | If it stops | |
|---|---|---|
tsjwt-controller |
Decide routes, provision data planes, report status | Traffic continues. Routes are already mounted. |
tsjwt-dataplane |
Terminate the connection, mint, forward | That Gateway stops serving. |
The data plane has no Kubernetes dependency at all — routes arrive as a mounted file. A component whose job is to keep serving should not depend on the thing most likely to be unavailable during an incident. It also caches the last table that parsed, so a restart during an outage serves the routes it had rather than none.
Install with kubectl apply -k gateway/deploy, after the
Gateway API CRDs.
| Annotation | Effect |
|---|---|
tsjwt.dev/audience |
Override the derived audience |
tsjwt.dev/tenant |
Pin the route to one tenant |
tsjwt.dev/claim-headers |
Write claims into headers, e.g. X-User=email |
claim-headers is a migration path. It lets a backend that already
trusts an identity header move behind the gateway unchanged, with the header
now written from an identity the gateway established itself. A backend reading
it is still trusting its network position; a backend verifying the assertion
is not.
| Binary | Use |
|---|---|
tsjwtd |
One service, no Kubernetes. Routes from flags. |
tsjwt-dataplane |
Behind the Gateway API controller. Routes from a file. |
tsjwt-controller |
The Gateway API controller. |
tsjwt-echo |
Reference backend. Verifies and echoes the whole assertion. |
Prototype, running in one deployment. The API may change.
The one assumption that is not yet measured: that a hostile peer cannot change what the identity source reports about it. Everything else here rests on that. See SECURITY.md.