jwt

jwt provides an HTTP-oriented JWT authentication helper for username/password login flows: validate user credentials, issue short-lived signed JWTs, authorize protected endpoints from an Authorization header, and optionally renew tokens near expiration.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/jwt"

Package jwt provides an HTTP-oriented JWT authentication helper for username/password login flows: validate user credentials, issue short-lived signed JWTs, authorize protected endpoints from an Authorization header, and optionally renew tokens near expiration.

The API is compatible with net/http:

  • [JWT.LoginHandler]: validates credentials and issues signed JWTs.
  • [JWT.RenewHandler]: renews a valid token only when it is close to expiry.
  • [JWT.IsAuthorized]: validates bearer tokens for protected handlers.
  • [JWT.Middleware]: wraps a handler, injecting verified claims into the request context for retrieval via ClaimsFromContext.
  • [JWT.Authenticate]: validates a bearer token and returns its claims, for building custom middleware.
  • [JWT.IssueToken]: mints a token outside the HTTP login flow, once the caller has verified the user’s identity by its own means.
  • [JWT.VerifyToken]: validates a raw token string that arrived over any transport (WebSocket messages, queue payloads, gRPC metadata).

Credential verification is delegated to a caller-provided VerifyCredentialsFn, so the package is agnostic to the password-hashing scheme; use the OWASP-compliant github.com/tecnickcom/nurago/pkg/passwordhash (Argon2id) for storage.

Implementation

Tokens are RFC 7515 compact JWS with RFC 7519 claims, signed with HMAC-SHA2 (RFC 7518 §3.2: HS256, HS384 or HS512). The implementation is self-contained on the Go standard library. Restricting the surface to symmetric HMAC makes the classic JWT attacks (alg=none, asymmetric-to-HMAC confusion) structurally impossible: the accepted algorithm is pinned and the signature is verified before the claims payload is ever decoded. A crit header (RFC 7515 §4.1.11) or a duplicated header parameter is rejected; other unknown JOSE header parameters (typ, kid, …) are ignored. The exp and nbf time claims are validated (with optional leeway); iat is decoded but not validated, as only a key holder could forge it.

Authentication Flow

  1. The login endpoint decodes JSON credentials (username, password).
  2. A user-provided VerifyCredentialsFn checks them against the user store.
  3. On success, a JWT is signed with configured claims and returned as text.
  4. Downstream handlers validate Authorization: Bearer <token> via JWT.IsAuthorized, JWT.Middleware, JWT.Authenticate, or JWT.RenewHandler.

Claims and Defaults

By default the package issues short-lived HMAC-signed tokens with:

  • expiration: DefaultExpirationTime (5 minutes)
  • renew window: DefaultRenewTime (30 seconds before expiry)
  • header name: DefaultAuthorizationHeader (Authorization)
  • request body cap: DefaultMaxBodyBytes
  • token size cap: DefaultMaxTokenBytes

Issued tokens include standard registered claims (exp, iat, nbf, jti, sub) and an auth_time claim recording the original login. They support optional iss and aud via options. The sub (Subject) claim is set to the authenticated username. When iss and/or aud are configured, they are also enforced during verification: a token missing them, or carrying different values, is rejected.

Extension Points

Functional options allow custom behavior without replacing core handlers:

  • response output customization (WithSendResponseFn)
  • token/header settings (WithExpirationTime, WithRenewTime, WithAuthorizationHeader, WithSigningMethod, WithMaxBodyBytes, WithMaxTokenBytes)
  • session controls (WithMaxSessionLifetime, WithClockSkewLeeway)
  • key rotation (WithPreviousKeys)
  • claim metadata (WithClaimIssuer, WithClaimAudience)
  • logger customization (WithLogger)

Security Notes

  • Only HMAC signing methods (HS256/HS384/HS512) are supported: the same symmetric key both signs and verifies. Keep it secret, and at least as long as the signing method’s hash output (enforced by New).
  • To rotate the signing key without invalidating outstanding sessions, deploy the new key while listing the old one in WithPreviousKeys, then drop the old key once the rotation window (expiration time plus renew window) has elapsed.
  • Return uniform error messages for invalid credentials to avoid account enumeration (the default login path already does this). A VerifyCredentialsFn MUST also equalize its own timing between known and unknown users so existence does not leak through response latency.
  • Use HTTPS so bearer tokens are never exposed in transit.
  • The handlers do not restrict the HTTP method; the caller is responsible for routing login to POST and protected endpoints appropriately.
  • Tokens are stateless: there is no server-side revocation before exp, and renewing a token does not invalidate the previous one, which stays valid until its own expiration. Configure short expiration windows appropriate for your threat model, and bound how long a session may be kept alive by renewals with WithMaxSessionLifetime.
  • The package does not rate-limit or lock out repeated failed logins; brute-force protection (rate limiting, lockout, CAPTCHA) must be layered by the caller.
  • The default responder logs the full response body, including the issued token, at debug level. Where debug logs are retained, disable them or pass a redacting logger via WithLogger (see github.com/tecnickcom/nurago/pkg/redact, which detects JWT compact serializations).

When To Use

  • A service authenticates with username and password and issues short-lived tokens.
  • Protected endpoints authorize from the Authorization header.
  • Tokens should be renewable near expiry without a fresh login.

Example

ph := passwordhash.New()

// Pre-computed password hashes, as they would be stored in a user database.
aliceHash, err := ph.PasswordHash("s3cr3t-pw")
if err != nil {
	fmt.Println(err)

	return
}

users := map[string]string{"alice": aliceHash}

// A decoy hash to verify against for unknown users, so response timing does
// not reveal whether an account exists.
decoyHash, err := ph.PasswordHash("decoy-pw")
if err != nil {
	fmt.Println(err)

	return
}

verify := func(username, password string) (bool, error) {
	hash, ok := users[username]
	if !ok {
		_, _ = ph.PasswordVerify(password, decoyHash)

		return false, nil
	}

	return ph.PasswordVerify(password, hash)
}

auth, err := jwt.New([]byte("0123456789abcdef0123456789abcdef"), verify)
if err != nil {
	fmt.Println(err)

	return
}

ctx := context.Background()

// Log in with valid credentials and capture the issued token.
loginRec := httptest.NewRecorder()
loginReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/login", strings.NewReader(`{"username":"alice","password":"s3cr3t-pw"}`))
auth.LoginHandler(loginRec, loginReq)

fmt.Println("login status:", loginRec.Code)

token, _ := io.ReadAll(loginRec.Result().Body)

// Protect an endpoint with the middleware: the verified claims are available
// from the request context, identifying the caller.
protected := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	claims, _ := jwt.ClaimsFromContext(r.Context())
	fmt.Fprintln(w, "hello", claims.Subject)
}))

authRec := httptest.NewRecorder()
authReq := httptest.NewRequestWithContext(ctx, http.MethodGet, "/protected", nil)
authReq.Header.Set(httputil.HeaderAuthorization, httputil.HeaderAuthBearer+string(token))
protected.ServeHTTP(authRec, authReq)

fmt.Println("protected status:", authRec.Code)
fmt.Print(authRec.Body.String())

// Output:
// login status: 200
// protected status: 200
// hello alice

Full source is in example_jwt_test.go. More runnable examples are on pkg.go.dev.

Dependencies

Importing this package pulls 1 external module:

  • github.com/julienschmidt/httprouter