Skip to content

Latest commit

 

History

56 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tsjwt

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.

How it works

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
Loading

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.

What it is not

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.

Design

caller ──WireGuard──▶ signer ──X-Tailnet-Jwt-Assertion──▶ backend
                        │                                    │
                     WhoIs()                            verify against
                  (the trust anchor)                   /.well-known/jwks.json
  1. 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.
  2. It resolves that identity to the tenants it may act for, and the roles it holds in each.
  3. It mints an ES256 JWT, short-lived, with a unique jti, and injects it. Any inbound copy of the header is stripped first.
  4. 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.

Layout

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.

Security decisions, and why

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.

Tenancy

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.

Use

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.

Claims

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.

Limits

  • 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.

Many backends

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.

Kubernetes: Gateway API

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.

Annotations

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.

Which binary

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.

Status

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.

About

Tailscale identity to short-lived JWT: an identity-aware proxy and verifier

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages