[GH-ISSUE #5302] ES256 JWT validation fails when JWKS key contains x5c field #11124

Closed
opened 2026-08-05 01:28:35 -04:00 by saavagebueno · 1 comment
Owner

Originally created by @AblabiX on GitHub (Feb 12, 2026).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/5302

Describe the problem

When using an OIDC provider (tested with Authentik) configured with ES256/ECDSA
signing keys, JWT validation fails with the following error even though the JWKS
endpoint correctly exposes an EC key:

token could not be parsed: token is unverifiable: error while executing keyfunc: 
key is not a valid RSA public key

The authentication works correctly with RSA/RS256 keys, but fails with EC/ES256 keys when the x5c field is present in the JWKS response.

To Reproduce

Steps to reproduce the behavior:

  1. Configure an OIDC provider (e.g., Authentik) with an EC P-256 signing key
  2. Ensure the JWKS endpoint returns the key with the x5c field populated
  3. Configure NetBird management server with AuthKeysLocation pointing to the JWKS URL
  4. Attempt to authenticate using a JWT token signed with ES256
  5. Authentication fails with "key is not a valid RSA public key" error

JWKS Key Structure (triggering the bug)

{
  "keys": [
    {
      "alg": "ES256",
      "kid": "example-kid",
      "kty": "EC",
      "use": "sig",
      "crv": "P-256",
      "x": "...",
      "y": "...",
      "x5c": ["..."],
      "x5t": "...",
      "x5t#S256": "..."
    }
  ]
}

Expected behavior

The function should check kty first to determine the key type, then handle x5c appropriately based on that type. EC certificates should be parsed with jwt.ParseECPublicKeyFromPEM(), not jwt.ParseRSAPublicKeyFromPEM().

Are you using NetBird Cloud?

self-host NetBird's control plane.

NetBird version

  • NetBird version: v0.64.6

Is any other VPN software installed?

no

Additional context

  • IdP: Authentik 2025.12.3
  • JWT Signing key Algorithm: ES256
  • Affected file: shared/auth/jwt/validator.go on getPublicKey()
Originally created by @AblabiX on GitHub (Feb 12, 2026). Original GitHub issue: https://github.com/netbirdio/netbird/issues/5302 **Describe the problem** When using an OIDC provider (tested with Authentik) configured with ES256/ECDSA signing keys, JWT validation fails with the following error even though the JWKS endpoint correctly exposes an EC key: ``` token could not be parsed: token is unverifiable: error while executing keyfunc: key is not a valid RSA public key ``` The authentication works correctly with RSA/RS256 keys, but fails with EC/ES256 keys when the x5c field is present in the JWKS response. **To Reproduce** Steps to reproduce the behavior: 1. Configure an OIDC provider (e.g., Authentik) with an EC P-256 signing key 2. Ensure the JWKS endpoint returns the key with the x5c field populated 3. Configure NetBird management server with AuthKeysLocation pointing to the JWKS URL 4. Attempt to authenticate using a JWT token signed with ES256 5. Authentication fails with "key is not a valid RSA public key" error JWKS Key Structure (triggering the bug) ```json { "keys": [ { "alg": "ES256", "kid": "example-kid", "kty": "EC", "use": "sig", "crv": "P-256", "x": "...", "y": "...", "x5c": ["..."], "x5t": "...", "x5t#S256": "..." } ] } ``` **Expected behavior** The function should check kty first to determine the key type, then handle x5c appropriately based on that type. EC certificates should be parsed with jwt.ParseECPublicKeyFromPEM(), not jwt.ParseRSAPublicKeyFromPEM(). **Are you using NetBird Cloud?** self-host NetBird's control plane. **NetBird version** - NetBird version: v0.64.6 **Is any other VPN software installed?** no **Additional context** - IdP: Authentik 2025.12.3 - JWT Signing key Algorithm: ES256 - Affected file: shared/auth/jwt/validator.go on getPublicKey()
saavagebueno added the triage-needed label 2026-08-05 01:28:35 -04:00
Author
Owner

@AblabiX commented on GitHub (Jun 25, 2026):

Following up on my own report with a confirmed root cause, an additional related bug, and a proposed fix.


Root cause confirmed

The problem is in getPublicKey() in shared/auth/jwt/validator.go. The x5c check fires unconditionally before kty is ever inspected:

// x5c has unconditional precedence — always parsed as RSA
if len(jwks.Keys[k].X5c) != 0 {
    cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----"
    return jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) // ← hardcoded RSA
}
if jwks.Keys[k].Kty == "RSA" {
    return getPublicKeyFromRSA(jwks.Keys[k])
}
if jwks.Keys[k].Kty == "EC" {
    return getPublicKeyFromECDSA(jwks.Keys[k])
}

When an IdP includes x5c alongside an EC key (valid per RFC 7517 §4.7), the first branch fires and calls jwt.ParseRSAPublicKeyFromPEM on an EC certificate — producing the key is not a valid RSA public key error reported above.

Additional bug: kty: "OKP" / EdDSA (Ed25519) is also unhandled

While investigating this I found a second bug in the same block: there is no branch for kty: "OKP" (the JWKS key type for EdDSA / Ed25519 keys). The loop simply exits without matching and returns errKeyNotFound, causing all tokens signed with an Ed25519 key to be silently rejected.

Proposed fix

Both issues are resolved by making kty the primary discriminator and moving the x5c handling inside each branch. A getPublicKeyFromEdDSA function also needs to be added:

func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) {
    for k := range jwks.Keys {
        if token.Header["kid"] != jwks.Keys[k].Kid {
            continue
        }
        switch jwks.Keys[k].Kty {
        case "RSA":
            if len(jwks.Keys[k].X5c) != 0 {
                cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----"
                return jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
            }
            return getPublicKeyFromRSA(jwks.Keys[k])
        case "EC":
            if len(jwks.Keys[k].X5c) != 0 {
                cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----"
                return jwt.ParseECPublicKeyFromPEM([]byte(cert))
            }
            return getPublicKeyFromECDSA(jwks.Keys[k])
        case "OKP":
            if jwks.Keys[k].Crv == "Ed25519" {
                return getPublicKeyFromEdDSA(jwks.Keys[k])
            }
            return nil, fmt.Errorf("unsupported OKP curve: %s", jwks.Keys[k].Crv)
        }
    }
    return nil, errKeyNotFound
}

func getPublicKeyFromEdDSA(jwk JSONWebKey) (ed25519.PublicKey, error) {
    if jwk.X == "" {
        return nil, fmt.Errorf("eddsa key incomplete: missing X")
    }
    xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
    if err != nil {
        return nil, err
    }
    if len(xBytes) != ed25519.PublicKeySize {
        return nil, fmt.Errorf("eddsa key invalid size: %d (expected %d)", len(xBytes), ed25519.PublicKeySize)
    }
    return ed25519.PublicKey(xBytes), nil
}

The getPublicKeyFromEdDSA logic has been verified with Go tests covering round-trip encode/parse, sign+verify, wrong key size, empty X, and invalid base64.

Bonus: jwt.Parse() in ValidateAndParse() is also called without jwt.WithValidMethods(...), which golang-jwt explicitly recommends to prevent algorithm confusion attacks:

jwt.Parse(
    token,
    v.getKeyFunc(ctx),
    jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"}),
    jwt.WithAudience(v.audienceList...),
    jwt.WithIssuer(v.issuer),
    jwt.WithIssuedAt(),
)

All three issues are a single focused refactor of the same ~15 lines in validator.go. Happy to open a PR if the team is aligned on the approach.

<!-- gh-comment-id:4796083872 --> @AblabiX commented on GitHub (Jun 25, 2026): Following up on my own report with a confirmed root cause, an additional related bug, and a proposed fix. --- **Root cause confirmed** The problem is in `getPublicKey()` in [`shared/auth/jwt/validator.go`](https://github.com/netbirdio/netbird/blob/main/shared/auth/jwt/validator.go). The `x5c` check fires unconditionally before `kty` is ever inspected: ```go // x5c has unconditional precedence — always parsed as RSA if len(jwks.Keys[k].X5c) != 0 { cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----" return jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) // ← hardcoded RSA } if jwks.Keys[k].Kty == "RSA" { return getPublicKeyFromRSA(jwks.Keys[k]) } if jwks.Keys[k].Kty == "EC" { return getPublicKeyFromECDSA(jwks.Keys[k]) } ``` When an IdP includes `x5c` alongside an EC key (valid per [RFC 7517 §4.7](https://datatracker.ietf.org/doc/html/rfc7517#section-4.7)), the first branch fires and calls `jwt.ParseRSAPublicKeyFromPEM` on an EC certificate — producing the `key is not a valid RSA public key` error reported above. **Additional bug: `kty: "OKP"` / EdDSA (Ed25519) is also unhandled** While investigating this I found a second bug in the same block: there is no branch for `kty: "OKP"` (the JWKS key type for EdDSA / Ed25519 keys). The loop simply exits without matching and returns `errKeyNotFound`, causing all tokens signed with an Ed25519 key to be silently rejected. **Proposed fix** Both issues are resolved by making `kty` the primary discriminator and moving the `x5c` handling inside each branch. A `getPublicKeyFromEdDSA` function also needs to be added: ```go func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) { for k := range jwks.Keys { if token.Header["kid"] != jwks.Keys[k].Kid { continue } switch jwks.Keys[k].Kty { case "RSA": if len(jwks.Keys[k].X5c) != 0 { cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----" return jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) } return getPublicKeyFromRSA(jwks.Keys[k]) case "EC": if len(jwks.Keys[k].X5c) != 0 { cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----" return jwt.ParseECPublicKeyFromPEM([]byte(cert)) } return getPublicKeyFromECDSA(jwks.Keys[k]) case "OKP": if jwks.Keys[k].Crv == "Ed25519" { return getPublicKeyFromEdDSA(jwks.Keys[k]) } return nil, fmt.Errorf("unsupported OKP curve: %s", jwks.Keys[k].Crv) } } return nil, errKeyNotFound } func getPublicKeyFromEdDSA(jwk JSONWebKey) (ed25519.PublicKey, error) { if jwk.X == "" { return nil, fmt.Errorf("eddsa key incomplete: missing X") } xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) if err != nil { return nil, err } if len(xBytes) != ed25519.PublicKeySize { return nil, fmt.Errorf("eddsa key invalid size: %d (expected %d)", len(xBytes), ed25519.PublicKeySize) } return ed25519.PublicKey(xBytes), nil } ``` The `getPublicKeyFromEdDSA` logic has been verified with Go tests covering round-trip encode/parse, sign+verify, wrong key size, empty X, and invalid base64. **Bonus:** `jwt.Parse()` in `ValidateAndParse()` is also called without `jwt.WithValidMethods(...)`, which `golang-jwt` [explicitly recommends](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#WithValidMethods) to prevent algorithm confusion attacks: ```go jwt.Parse( token, v.getKeyFunc(ctx), jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"}), jwt.WithAudience(v.audienceList...), jwt.WithIssuer(v.issuer), jwt.WithIssuedAt(), ) ``` All three issues are a single focused refactor of the same ~15 lines in `validator.go`. Happy to open a PR if the team is aligned on the approach.
Sign in to join this conversation.
No Label triage-needed
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#11124