CWE-287: Improper Authentication - Go
Overview
Go's standard library has no built-in authentication framework - net/http routes requests to handlers, and login logic, session handling, and token verification are entirely application code written on top of it. That makes Go especially prone to improper authentication findings: there is no framework default to fall back on, so every gap (a handler that forgets to check for a valid session, a JWT keyFunc that trusts the token's own header, a password comparison that isn't constant-time) is a gap the application introduced directly.
The most common concrete failure is JWT verification with github.com/golang-jwt/jwt where the keyFunc callback returns a signing key without checking token.Method first - this lets the token's own alg header decide how it gets verified, including switching to none or from RS256 to HS256 (using the RSA public key as an HMAC secret). The fix is to assert the expected signing method inside keyFunc, pin valid methods on the parser, enforce authentication as middleware wrapping every protected handler rather than duplicated per-handler, and compare passwords with a constant-time hash function.
Common Vulnerable Patterns
JWT keyFunc That Trusts the Token's Own Algorithm Header
// VULNERABLE - keyFunc returns the key without checking which algorithm the token claims to use
func verifyToken(tokenString string) (*jwt.Token, error) {
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return hmacSecret, nil // returns the key regardless of t.Method
})
if err != nil {
return nil, err
}
return token, nil
}
// Attack example:
// A token re-signed with "alg": "none" and an empty signature, or with the
// server's RS256 public key reused as an HS256 secret, still verifies successfully
Why this is vulnerable: Because keyFunc never inspects t.Method, the parser uses whatever verification strategy the attacker-controlled header requests. Algorithm confusion has been the root cause of real CVEs in JWT libraries across languages, and Go's are no exception when the check is left out.
Session/Auth Check Duplicated (and Forgotten) Per Handler
// VULNERABLE - each handler is responsible for remembering its own auth check
func AccountHandler(w http.ResponseWriter, r *http.Request) {
// forgot to check the session here
account := lookupAccount(r.URL.Query().Get("id"))
json.NewEncoder(w).Encode(account)
}
func AdminHandler(w http.ResponseWriter, r *http.Request) {
if !sessionManager.Exists(r.Context(), "user_id") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// ... handle request
}
// Attack example:
// GET /account?id=12345 with no session cookie at all
// Result: account data returned because AccountHandler never checked for a session
Why this is vulnerable: Without a framework that enforces authentication centrally, it is easy to add a new handler and forget the check - as happened in AccountHandler above. This is a maintainability problem as much as a security one: the fix is structural (middleware), not "remember to add the check every time."
Non-Constant-Time Password Comparison
// VULNERABLE - plaintext comparison, and even a hash comparison here would be timing-unsafe
if submittedPassword == user.Password {
return issueSession(user)
}
Why this is vulnerable: Storing or comparing plaintext passwords means a database compromise exposes every credential directly, and == on strings short-circuits on the first differing byte, which can leak information about the correct value through timing in some contexts.
Secure Patterns
Pin the Signing Method Inside keyFunc, and on the Parser
// SECURE - keyFunc asserts the concrete signing method before returning a key
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
)
var hmacSecret = []byte(mustGetEnv("JWT_SECRET"))
func verifyToken(tokenString string) (*jwt.Token, error) {
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return hmacSecret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid token: %w", err)
}
return token, nil
}
Why this works: The type assertion inside keyFunc rejects any token whose header claims a signing method other than HMAC before a key is even returned, so an alg: none or RS256-to-HS256 confusion attempt fails at that check. jwt.WithValidMethods adds a second, parser-level allowlist so the accepted algorithm set is pinned in two independent places rather than inferred from attacker-controlled input. Because hmacSecret never changes based on what the token claims, there is no way for the token itself to influence how it gets verified.
Enforce Authentication as Middleware, Not Per-Handler
// SECURE - authentication is centralized; a new handler is protected by wiring, not memory
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader { // no "Bearer " prefix present
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
token, err := verifyToken(tokenString)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims := token.Claims.(jwt.MapClaims)
ctx := context.WithValue(r.Context(), userContextKey, claims["sub"])
next(w, r.WithContext(ctx))
}
}
// mux.HandleFunc("/account", RequireAuth(AccountHandler))
// mux.HandleFunc("/admin", RequireAuth(AdminHandler))
Why this works: Wrapping each protected handler with RequireAuth moves the authentication check out of individual handler bodies and into the routing table, where it is visible in one place and applied consistently. A route registered without RequireAuth is still reachable unauthenticated - middleware cannot make that impossible - but the omission shows up on the route line rather than as an absence buried in a handler body, so the check lives in the wiring rather than in every handler author's memory. The user identity extracted from verifyToken's validated claims, not from any client-supplied header, is what downstream handlers read via the request context.
Constant-Time, Salted Password Verification
// SECURE - bcrypt hashes and verifies with a constant-time comparison internally,
// and an unknown username costs the same as a wrong password
import "golang.org/x/crypto/bcrypt"
// A real bcrypt hash at the same cost as the stored ones, so verifying against it
// costs what a real verification costs. Deriving it at start-up rather than pasting
// in a literal keeps it correct if bcrypt.DefaultCost changes: a dummy at a
// different cost is a timing gap of its own. CompareHashAndPassword returns
// ErrHashTooShort in microseconds for "" or a malformed string, so the dummy has
// to be a genuine hash.
var dummyHash = mustHash("this value is never submitted")
func mustHash(password string) []byte {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return hash
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
// authenticateUser returns the user only when the password verifies. Both branches
// run bcrypt, so the response time does not say which usernames exist.
func authenticateUser(username, submittedPassword string) (*User, bool) {
user, err := lookupUser(username)
// Fall back to the dummy for an unknown username and for a row with no password
// set (an SSO-only account): CompareHashAndPassword returns ErrHashTooShort
// immediately on an empty hash, which would time that case instead.
storedHash := dummyHash
if err == nil && user != nil && user.PasswordHash != "" {
storedHash = []byte(user.PasswordHash)
}
verifyErr := bcrypt.CompareHashAndPassword(storedHash, []byte(submittedPassword))
if err != nil || user == nil || user.PasswordHash == "" || verifyErr != nil {
return nil, false
}
return user, true
}
Why this works: bcrypt.CompareHashAndPassword hashes the submitted password with the same salt stored in storedHash and compares the result using a constant-time comparison internally, so timing does not leak information about how close a guess was. Because bcrypt stores a per-password salt and a configurable cost factor, a database leak does not expose usable plaintext credentials, and the cost factor can be raised over time as hardware gets faster.
Running CompareHashAndPassword on every branch is what stops the response time from answering "does this username exist". Returning as soon as lookupUser fails skips the whole cost: measured on Go 1.25 at bcrypt.DefaultCost, a wrong password takes about 51 ms and a lookup miss returns in microseconds, and any client can time that. The empty-hash case is the same defect one row in - a user record with no password set is answered in 0.000 ms while everyone else waits 51 ms, which enumerates SSO-only accounts rather than usernames. Every login now pays the full hashing cost, so rate-limit the endpoint (see below). The same shape applies to any lookup-then-verify flow: password reset token redemption, API key checks and TOTP validation all leak existence if the "no such record" branch is the cheap one.
Framework-Specific Guidance
net/http Middleware Chaining
Use net/http's handler-wrapping pattern (as in RequireAuth above) or a lightweight router that supports middleware groups (chi, gorilla/mux) to apply authentication to a whole route group in one place, rather than one call at a time:
// SECURE - chi router applies RequireAuth to an entire route group
r := chi.NewRouter()
r.Group(func(protected chi.Router) {
protected.Use(RequireAuthMiddleware)
protected.Get("/account", AccountHandler)
protected.Get("/admin", AdminHandler)
})
r.Get("/health", HealthHandler) // outside the group - intentionally public
Grouping protected routes together makes "which routes require authentication" a property of the router configuration, reviewable in one place, instead of an implicit property of each handler's own code.
Session Rotation on Login with scs
// SECURE - the session identifier is rotated before the login is recorded
import (
"net/http"
"time"
"github.com/alexedwards/scs/v2"
)
var sessionManager *scs.SessionManager
func main() {
sessionManager = scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = true
sessionManager.Cookie.SameSite = http.SameSiteStrictMode
mux := http.NewServeMux()
mux.HandleFunc("/login", LoginHandler)
http.ListenAndServe(":8443", sessionManager.LoadAndSave(mux))
}
func LoginHandler(w http.ResponseWriter, r *http.Request) {
user, ok := authenticateUser(r.FormValue("username"), r.FormValue("password"))
if !ok {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
// Rotate the identifier before anything records the privilege change.
// The error matters: if the old token was not deleted, the login is
// about to be written under an identifier the attacker may still hold.
if err := sessionManager.RenewToken(r.Context()); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sessionManager.Put(r.Context(), "user_id", user.ID)
}
Why this works: RenewToken is an explicit rotation call, which is the operation this weakness needs and the thing most Go session libraries do not expose. Its documented behaviour is that the new token replaces the old one and "the old session token and accompanying data are deleted from the session store", so an identifier planted on the victim's browser before login stops resolving to anything the moment the login succeeds. Calling it before Put is not stylistic: the vendor's own guidance is to renew before any privilege-level change, and a rotation that happens after the authenticated state is written has already exposed that state under the old identifier. Handle the returned error rather than discarding it - it is non-nil when the store failed to delete the old record, which is exactly the case where continuing would record the login against an identifier the attacker still has. RenewToken must run once per request cycle and before any response header is written, so it belongs at the top of the post-authentication path rather than next to the redirect.
What it does not do is clear the session contents: RenewToken "updates the session data to have a new session token while retaining the current session data". That is the right default for fixation, where the identifier is the thing the attacker knows, but it means anything written into the pre-login session survives the rotation. Where an unauthenticated visitor can write session values your authenticated code later trusts, pair it with Clear(), or use Destroy() and build the session fresh - and be deliberate about the pre-login state you do want to carry across, such as a cart or a returnTo path.
Rate Limiting and Authentication Logging
Neither control stops a credential check from being wrong, but both change how long an attacker can keep trying and whether anyone notices. Rate limit the login and token endpoints specifically rather than globally - a limit generous enough for normal API traffic is useless against password guessing. Log authentication outcomes, successes as well as failures, with enough context to distinguish one account under attack from broad credential stuffing, and never log the submitted credential itself.
Testing
- Submit a request to a protected route with no
Authorizationheader - expect401. - Re-sign a valid token with
alg: noneor swap the algorithm family (RS256 public key reused as an HS256 secret) and confirmParseWithClaimsreturns an error, not a valid token. - Submit an expired token (
expin the past) - expect rejection; confirmjwt.WithValidMethodsand expiration checks are both exercised in a table-driven test. - Add a new handler to a protected route group and confirm it is unreachable without a valid token, verifying the middleware wiring rather than trusting that every handler remembered its own check.
- Test password verification with a correct password, an incorrect password, and an empty password against
bcrypt.CompareHashAndPassword. - Capture the session cookie before logging in, authenticate, then replay the captured cookie: it must no longer resolve to a session, and the cookie the login returned must be a different value. Asserting only that a new cookie was set passes against a handler that kept the old identifier alive alongside it.
- Time four calls to
authenticateUser- known username with the right password, known username with a wrong password, unknown username, and a user row whosePasswordHashis empty - and assert all four are within noise of each other. A microsecond answer for any of them is the enumeration oracle, and a re-scan cannot see it. - Re-run the scanner or tool that reported the finding and confirm it no longer triggers.
Common Pitfalls
- Checking
token.Validwithout checking the returnederr, or vice versa -golang-jwtcan return a non-nil token alongside a non-nil error in some parse-failure paths; only trust a token when botherr == nilandtoken.Valid == true. - Adding a new route directly on the router instead of inside the authenticated group, which silently exposes it - this is the structural risk middleware is meant to close; a periodic route audit still catches routes added outside the group.
- Listing multiple algorithms in
jwt.WithValidMethods(for example bothHS256andRS256) "to be safe" when the issuer only ever signs with one - every additional accepted algorithm is additional attack surface for algorithm-confusion, not additional safety. - Treating
gorilla/sessions'store.Newas session regeneration: it is not.CookieStore.Newreads the request cookie, decodes it intosession.Valuesand setsIsNew = falsewhen the decode succeeds;FilesystemStore.Newdecodes the cookie intosession.IDand loads the stored values off it. The library's own doc comment gives the only difference fromGet- "calling New() twice will decode the session data twice, while Get() registers and reuses the same decoded session after the first call" - andFilesystemStore.Savemints a new identifier only whensession.ID == "". So a login handler that callsNewand thenSavewrites the authenticated user into the session the attacker planted, with nothing in the code reading as wrong. The library exposes no rotation call; use one that does, or erase the old record and save a session whoseIDis empty. - Reading the user ID for authorization decisions from a request header or query parameter set alongside the token (for example
X-User-Id) instead of exclusively from the verified token's claims - this reintroduces a client-trust bypass even when JWT verification itself is correct.
Dependencies and Installation
github.com/golang-jwt/jwt/v5- the current maintained major version;go get github.com/golang-jwt/jwt/v5. Earlierv3/v4releases are still common in existing code but should be upgraded when touched, since thekeyFuncsignature and validation options changed across versions.golang.org/x/crypto/bcrypt-go get golang.org/x/crypto/bcryptfor password hashing.github.com/alexedwards/scs/v2- session management with an explicitRenewToken()rotation call;go get github.com/alexedwards/scs/v2. Any session library will do provided it exposes identifier rotation as an operation, which is the requirement this weakness imposes;gorilla/sessionsdoes not, which is why the example above does not use it (see Common Pitfalls). Session data stays server-side in the configured store and the cookie carries only a 32-byte random token, so there is no signing key to manage in the default configuration - where a store you choose does need keys, read them from a secret manager rather than from an environment variable that keeps the key, see CWE-526.