Router enrollment fails with "token signature is invalid" - JWT kid vs EST cacerts mismatch

Ziti Version: v1.1.3 (ARM64, Amazon Linux 2023)
Deployment: Custom PKI on bare EC2 (not K8s, not quickstart)

Problem

Router enrollment fails with:

token signature is invalid: crypto/rsa: verification error

Root Cause Analysis

After extensive debugging, I found a mismatch between JWT verification expectations:

  1. JWT kid = TLS server cert fingerprint (e.g., 281f0bba...)
  2. EST cacerts (/.well-known/est/cacerts) only returns CA certs (intermediate + root)
  3. Server cert (CA:FALSE) is NOT in EST cacerts
  4. Router enrollment tries to verify JWT using cert from EST cacerts matching kid β†’ No match β†’ Fail

PKI Structure

  • root-ca (CA:TRUE)
  • intermediate-ca (CA:TRUE) ← signingCert
  • server.cert (CA:FALSE) ← TLS identity

Controller Config (relevant parts)

identity:
server_cert: /path/to/server.chain.pem  # server + intermediate + root

edge:
enrollment:
signingCert:
cert: /path/to/intermediate-ca.cert
key: /path/to/intermediate-ca.key

Observed Behavior

  • JWT is signed correctly (signingCert)
  • JWT kid is set to TLS server cert fingerprint (from server_cert chain first cert)
  • EST cacerts returns intermediate + root (because they're CA:TRUE)
  • Router can't find cert matching kid in EST cacerts to verify signature

What I've Tried

  • Using intermediate CA for TLS β†’ Controller crashes (expects leaf cert)
  • Different PKI structures matching quickstart β†’ Same issue
  • Fresh JWTs (not expired, not reused)

Questions

Is this expected behavior?

How should external router enrollment work when the TLS cert is different from the signing cert?

Quickstart works because it uses internal enrollment that bypasses external JWT verification.

Hi @jfin, welcome to the community and to OpenZiti

Not gonna lie - this line does scare me as someone trying to support the community. Getting the PKI right is a difficult thing to do and not for the faint of heart. I expect you've misconfigured it somehow. Also this statement at the end of your post is incorrect. *Quickstart works because it uses internal enrollment that bypasses external JWT verification.*. That is not true, don't be misled...

I'd suggest you read through this older thread OpenZiti network from scratch - #10 by nenkoru and possibly watch the Ziti TV if you need to. Community member @nenkoru put up GitHub - nenkoru/openziti_manual_pki: Bootstrap PKI for OpenZiti manually which is referenced in that thread.

I think that's really waht you need. If you follow that and still have questions, I think we can go from there?

Getting the PKI right is a difficult thing to do and not for the faint of heart. I expect you've misconfigured it somehow.

Totally fair.

Also this statement at the end of your post is incorrect
I'd suggest you read through this older thread…

… I think we can go from there?

I’ll go educate myself. Thank you for pointing me in the right direction. I appreciate it!

1 Like

SOLVED - Router Enrollment JWT Signature Issue (PKI + ALB Learnings)


TL;DR: We had TWO major issues:

  1. Outdated Ziti version (v1.1.3) with improper PKI structure
  2. AWS ALB terminating TLS instead of TCP passthrough

Both needed to be fixed. Here's everything we learned.


Part 1: PKI and Version Issues

The Old Version Problem

We initially downloaded Ziti from https://get.openziti.io/latest/arm64 which gave us v1.1.3 (May 2024) - over a year old! The latest is v1.6.9.

Lesson: Always verify the version you're installing. The "latest" URL may not be current.

The PKI Structure We Needed

After studying nenkoru's manual PKI guide and the from-scratch.sh script, we learned that Ziti expects a multi-level PKI with separate intermediate CAs for different purposes:

Root CA (my-root-ca)
└── External ICA (my-external-ica)
    β”œβ”€β”€ Network Components ICA (my-network-ica)
    β”‚   β”œβ”€β”€ network-client.cert (identity.cert)
    β”‚   └── network-server.chain.pem (identity.server_cert)
    β”‚
    β”œβ”€β”€ Edge ICA (my-edge-ica)  
    β”‚   β”œβ”€β”€ edge-client.cert (web.identity.cert)
    β”‚   └── edge-server.chain.pem (web.identity.server_cert)
    β”‚
    └── Signing ICA (my-sign-ica)
        └── my-sign-ica.chain.pem (edge.enrollment.signingCert)

Using ziti pki Commands

We learned to use Ziti's built-in PKI tools instead of raw OpenSSL:

# Create Root CA
ziti pki create ca --pki-root /opt/ziti/pki --ca-name my-root-ca

# Create Intermediate CAs
ziti pki create intermediate --pki-root /opt/ziti/pki \
  --ca-name my-root-ca \
  --intermediate-name my-external-ica

ziti pki create intermediate --pki-root /opt/ziti/pki \
  --ca-name my-external-ica \
  --intermediate-name my-network-ica

# Create server/client certs with proper SANs
ziti pki create server --pki-root /opt/ziti/pki \
  --ca-name my-network-ica \
  --server-name network-server \
  --dns "localhost,ziti.example.com" \
  --ip "127.0.0.1"

ziti pki create client --pki-root /opt/ziti/pki \
  --ca-name my-network-ica \
  --client-name network-client

The Controller Config Structure

We learned the controller config has three distinct identity sections:

# 1. Main identity (for fabric/ctrl plane)
identity:
  cert: /opt/ziti/pki/my-network-ica/certs/network-client.cert
  server_cert: /opt/ziti/pki/my-network-ica/certs/network-server.chain.pem
  key: /opt/ziti/pki/my-network-ica/keys/network-components.key
  ca: /opt/ziti/pki/my-network-ica/cas-full.pem

# 2. Signing cert (for signing enrolled identity certs via EST)
edge:
  enrollment:
    signingCert:
      cert: /opt/ziti/pki/my-sign-ica/certs/my-sign-ica.chain.pem
      key: /opt/ziti/pki/my-sign-ica/keys/my-sign-ica.key

# 3. Web identity (for edge/management API - THIS IS WHAT SIGNS THE JWT!)
web:
  - name: all-apis-localhost
    identity:
      cert: /opt/ziti/pki/my-edge-ica/certs/edge-client.cert
      server_cert: /opt/ziti/pki/my-edge-ica/certs/edge-server.chain.pem
      key: /opt/ziti/pki/my-edge-ica/keys/edge-components.key
      ca: /opt/ziti/pki/my-edge-ica/edge-cas-full.pem

Key PKI Learnings

  1. Chain files matter - Use *.chain.pem for server_cert to include the full chain
  2. Separate ICAs for separate purposes - Don't use the same cert for everything
  3. cas-full.pem for trust anchors - Include the full CA chain up to root
  4. SANs must match - Server certs need DNS/IP SANs that match how clients connect
  5. signingCert β‰  JWT signing - The signingCert signs enrolled identity certs, NOT the enrollment JWT

Part 2: The ALB Problem (The Final Piece)

Even after fixing all the PKI issues, enrollment still failed with "token signature is invalid."

Source Code Analysis

We cloned the OpenZiti repos and read the actual code:

sdk-golang/ziti/enroll/enroll.go:

func ValidateToken(token *jwt.Token) (interface{}, error) {
    cert, err := FetchServerCert(claims.Issuer)  // Gets TLS cert from connection
    claims.SignatureCert = cert
    return cert.PublicKey, nil  // Verifies JWT with this cert's public key!
}

ziti/controller/env/appenv.go:

func (ae *AppEnv) GetEnrollmentJwtSigner() (jwtsigner.Signer, error) {
    enrollmentCert, err := ae.getEnrollmentTlsCert()  // Gets web identity TLS cert
    kid := fmt.Sprintf("%x", sha1.Sum(enrollmentCert.Certificate[0]))
    return jwtsigner.New(signMethod, enrollmentCert.PrivateKey, kid), nil
}

The Discovery

The enrollment JWT is signed using the web identity's TLS certificate private key. The router verifies it by:

  1. Connecting to the controller URL
  2. Getting the TLS certificate from the connection
  3. Using that cert's public key to verify the JWT

Our AWS ALB was terminating TLS with an ACM certificate, so the router got Amazon's cert instead of our controller's cert!

The Fix

Changed from ALB (Application Load Balancer with HTTPS termination) to NLB (Network Load Balancer with TCP passthrough):

resource "aws_lb" "ziti" {
  load_balancer_type = "network"  # NOT "application"
}

resource "aws_lb_listener" "tcp" {
  protocol = "TCP"  # NOT "HTTPS"
  # No certificate_arn - passthrough!
}

Router enrollment succeeded immediately after this change.


Summary of All Learnings

Issue Learning
Old version Always verify Ziti version - don't trust "latest" URLs
PKI structure Use multi-level ICAs: Network, Edge, Signing
Cert generation Use ziti pki commands, not raw OpenSSL
Chain files Always use *.chain.pem for server_cert
signingCert Signs enrolled certs, NOT the enrollment JWT
JWT signing Done by web.identity TLS cert's private key
Load balancers MUST use TCP passthrough, not TLS termination

Deployment Requirements

For enrollment to work, the controller's TLS certificate must be presented directly to clients.

:white_check_mark: Works: NLB (TCP), direct exposure, nginx stream mode, HAProxy TCP mode

:cross_mark: Breaks: ALB (HTTPS), any L7 proxy with TLS termination, Cloudflare proxy mode


Thanks again to @TheLumberjack for the guidance. The PKI guide was essential - it led us down the right path and eventually to reading the source code where we found the final piece of the puzzle!


1 Like

LOTS of good info here! :slight_smile: Thanks for summarizing!

I wouldn't quite state it this strongly - that was just how @nenkoru did it and it seemed totally reasonable to me. :slight_smile: It's not mandatory. You could use the same ca and intermediate everywhere if you really wanted to i believe... like we do for testing/dev stuff ziti/etc/ctrl.yml at main Β· openziti/ziti Β· GitHub. Also see ziti/doc/ha/create-pki.sh at main Β· openziti/ziti Β· GitHub if you want to see yet another way of making your own pki using the ziti cli.

The ALB problem is real. You can never terminate TLS with OpenZiti. It's fundamentally based on mTLS everywhere. I wouldn't have guessed you had this setup but I had expected this was the likely culprit (the server cert presented) but it was much easier to point you at a functional example than try to intuit it from afar. :slight_smile:

BTW - impressive job figuring all that out at this speed! Go @jfin!

Thanks! And trulyβ€”couldn’t have pieced this all together without your earlier tip about β€œfollow a known-good example.” That sent me down the right path.

Also, full disclosure: I had an insanely capable AI assistant helping me sift through the source, untangle the PKI layers, and validate assumptions as I went. But your pointer is what made the entire chain of discoveries possible. Appreciate the guidance and the OpenZiti team’s openness in the docs and reposβ€”made it possible to learn fast and fix things the right way.