This guide outlines security best practices when using oauth-mcp-proxy in production.
The following security improvements introduce breaking changes:
What changed: OIDC providers (Okta, Google, Azure) now enforce HTTPS validation for issuer URLs in Config.Validate().
Why: Prevents man-in-the-middle attacks on OAuth communication.
Impact: Invalid issuer URLs will cause NewServer() to fail with error.
Migration:
// β
Valid configurations
Issuer: "https://company.okta.com" // Production
Issuer: "http://localhost:8080" // Local testing only
Issuer: "http://127.0.0.1:8080" // Local testing only
// β Invalid - will fail validation
Issuer: "http://company.okta.com" // Must use HTTPS
Issuer: "company.okta.com" // Missing scheme
Issuer: "https://192.168.1.1/issuer" // IP addresses not allowedWhat changed: NewServer() now panics if the state signing key cannot be generated via crypto/rand.
Why: Prevents weak fallback that could allow state forgery attacks.
Impact: Server startup will fail immediately if crypto/rand fails.
Migration: No code changes needed. Ensure your system has a working CSPRNG (crypto/rand). This should never fail on healthy systems.
What changed: generateSecureNonce() now panics instead of falling back to weak timestamp-based nonces.
Why: Timestamp-based nonces are predictable and vulnerable to replay attacks.
Impact: OAuth authorization requests will fail if crypto/rand fails.
Migration: No code changes needed. Ensure your system has a working CSPRNG.
What changed: CreateRequestAuthHook() now returns an error for all requests instead of silently allowing them through.
Why: The previous implementation returned nil (allow-all), which created a security bypass if integrators relied on this hook for authentication. The hook's signature cannot propagate context changes, making it fundamentally unable to perform real auth.
Impact: Any code using CreateRequestAuthHook() will now reject all requests with an error.
Migration: Switch to WithOAuth() tool-level middleware, which properly handles authentication and context propagation:
// β Old (deprecated, now fails all requests)
hook := oauth.CreateRequestAuthHook(validator)
// β
New
oauthServer, oauthOption, _ := mark3labs.WithOAuth(mux, &oauth.Config{...})
mcpServer := server.NewMCPServer("name", "1.0.0", oauthOption)What changed: Config.Validate() now validates redirect URIs and fixed redirect URIs at startup. HTTPS is required for non-localhost URIs, fragments are rejected, and whitespace-only URI lists are caught.
Why: Prevents open redirect vulnerabilities and ensures OAuth 2.0 spec compliance.
Impact: Existing configs with HTTP redirect URIs for non-localhost hosts, or URIs containing fragments, will fail validation at startup.
Migration:
// β
Valid
RedirectURIs: "https://app.example.com/callback"
RedirectURIs: "http://localhost:3000/callback"
// β Invalid - will fail validation
RedirectURIs: "http://app.example.com/callback" // Must use HTTPS
RedirectURIs: "https://app.example.com/cb#fragment" // No fragments allowed
RedirectURIs: " , , " // No valid URIsWhat changed: Security-sensitive error paths now return generic error messages to prevent information leakage.
Why: Prevents attackers from learning internal system details through error messages.
Impact: Debugging authentication failures from client-side may be less informative.
Migration: Use server logs for detailed debugging. Client-facing errors are intentionally generic for security.
The following security improvements are fully backward-compatible:
- Token cache expiry fix - Cache now respects JWT expiration times
- State replay protection - Legacy states without timestamp/nonce still accepted for rolling deploys
- Input validation - Only affects malformed/abusive requests
- Query injection prevention - Transparent fix, no API changes
- go-sdk adapter session management - Fully backwards compatible
β BAD:
oauth.WithOAuth(mux, &oauth.Config{
JWTSecret: []byte("my-secret-key"), // Committed to git!
ClientSecret: "hardcoded-secret", // Committed to git!
})β GOOD:
oauth.WithOAuth(mux, &oauth.Config{
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"),
})# .env (add to .gitignore!)
JWT_SECRET=your-random-32-byte-secret-key-here
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_ISSUER=https://yourcompany.okta.comLoad with library like godotenv:
import "github.com/joho/godotenv"
func main() {
godotenv.Load() // Load .env file
oauth.WithOAuth(mux, &oauth.Config{
Provider: os.Getenv("OAUTH_PROVIDER"),
Issuer: os.Getenv("OAUTH_ISSUER"),
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"),
})
}# Secrets
.env
.env.local
.env.production
# Certificates
*.pem
*.key
*.crt
# OAuth tokens (testing)
*.token// Generate cryptographically secure secret
secret := make([]byte, 32) // 32 bytes = 256 bits
if _, err := rand.Read(secret); err != nil {
log.Fatal(err)
}
// Store as base64 or hex
secretB64 := base64.StdEncoding.EncodeToString(secret)
fmt.Println("JWT_SECRET=" + secretB64)secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) < 32 {
log.Fatal("JWT_SECRET must be at least 32 bytes for security")
}- Rotate every: 90 days recommended
- Process: Generate new secret β Update config β Deploy β Update token generators
- Zero downtime: Temporarily accept both old and new secrets during rotation
β NEVER in production:
http.ListenAndServe(":80", mux) // Unencrypted!β Production:
http.ListenAndServeTLS(":443", "server.crt", "server.key", mux)Development:
- Use mkcert for local testing
Production:
- Use Let's Encrypt with certbot
- Or your cloud provider's certificate service (AWS ACM, GCP Certificate Manager)
// Auto-reload certificates
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("your-server.com"),
Cache: autocert.DirCache("certs"),
}
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: certManager.TLSConfig(),
}
server.ListenAndServeTLS("", "")Prevents token reuse across services:
Service A: Audience = "api://service-a"
Service B: Audience = "api://service-b"
Token for Service A cannot be used on Service B (even with same issuer).
HMAC Provider:
oauth.WithOAuth(mux, &oauth.Config{
Provider: "hmac",
Audience: "api://my-specific-mcp-server", // Unique per service
})OIDC Providers:
- Okta: Configure custom audience in auth server claims
- Google: Use Client ID as audience
- Azure: Use Application ID or custom App ID URI
// Token must have matching audience
{
"aud": "api://my-specific-mcp-server", // Must match Config.Audience
"iss": "https://issuer.com",
"sub": "user-123"
}- Cache TTL: 5 minutes (hardcoded in v0.1.0)
- Cache scope: Per Server instance
- Cache key: SHA-256 hash of token
User tokens:
- Short-lived: 1 hour
- Refresh tokens: 7-30 days
- Reason: Limits damage if compromised
Service tokens:
- Medium-lived: 6-24 hours
- Reason: Balance between security and token refresh overhead
// When generating tokens
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "user-123",
"aud": "api://my-server",
"exp": time.Now().Add(1 * time.Hour).Unix(), // Expire in 1 hour
"iat": time.Now().Unix(),
})oauth-mcp-proxy automatically supports PKCE (RFC 7636):
- Prevents authorization code interception attacks
- Required for public clients (mobile, desktop, browser)
- Automatically validated when code_challenge provided
PKCE is automatically enabled when client provides:
code_challengeparameter in /oauth/authorizecode_verifierparameter in /oauth/token
Localhost only for security:
β
http://localhost:8080/callback
β
http://127.0.0.1:3000/callback
β
http://[::1]:9000/callback
β http://evil.com/callback (rejected)
β https://localhost.evil.com/... (rejected - subdomain attack)
Allowlist configuration:
oauth.WithOAuth(mux, &oauth.Config{
RedirectURIs: "https://app.example.com/callback", // Single URI (fixed)
// Or multiple:
// RedirectURIs: "https://app1.com/cb,https://app2.com/cb", // Allowlist
})Security checks:
- HTTPS required for non-localhost
- No fragment allowed (per OAuth 2.0 spec)
- Exact match validation (no wildcards)
Browser:
- Use
httpOnlycookies or sessionStorage (NOT localStorage) - Clear on logout
Mobile/Desktop:
- Use OS keychain (macOS Keychain, Windows Credential Manager)
- Never store in plain text files
CLI Tools:
- Store in encrypted config files
- Use OS-specific secure storage when possible
Always use Authorization header:
curl -H "Authorization: Bearer <token>" https://server.com/mcpNever:
- In URL query parameters (logged in web servers)
- In cookies without httpOnly flag
- In localStorage (XSS vulnerable)
oauth-mcp-proxy logs (with custom logger or default):
Info Level:
- Authorization requests
- Successful authentications
- Token cache hits
Warn Level:
- Security violations (invalid redirects)
- Configuration issues
Error Level:
- Token validation failures
- OAuth provider errors
β Safe: Token hash (SHA-256)
INFO: Validating token (hash: a7bc40a987f35871...)
β NEVER log: Full tokens
ERROR: Token xyz123... invalid // SECURITY VIOLATION!
type ProductionLogger struct {
logger *zap.Logger
}
func (l *ProductionLogger) Error(msg string, args ...interface{}) {
// Sanitize before logging
l.logger.Sugar().Errorf(msg, args...)
// Send to error tracking (Sentry, etc.)
}
oauth.WithOAuth(mux, &oauth.Config{
Logger: &ProductionLogger{logger: zapLogger},
})oauth-mcp-proxy includes a built-in rate limiter:
import "github.com/tuannvm/oauth-mcp-proxy"
limiter := oauth.NewRateLimiter(time.Minute, 100) // 100 req/min
if !limiter.Allow("client-key") {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}Features:
- Fixed-window rate limiting
- Automatic cleanup of expired entries
- Thread-safe (uses sync.RWMutex)
- Background cleanup goroutine support
// Start background cleanup (prevents memory leaks)
stopCleanup := limiter.StartCleanup(5 * time.Minute)
defer stopCleanup()For OAuth endpoints, consider additional rate limiting:
import "golang.org/x/time/rate"
globalLimiter := rate.NewLimiter(10, 20) // 10 req/s, burst 20
func rateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !globalLimiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// Apply to OAuth endpoints
mux.Handle("/oauth/", rateLimitMiddleware(oauthHandler))OAuth handler automatically adds security headers:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache
Content-Security-Policy: default-src 'none'; script-src 'none'; style-src 'none'; img-src 'none'; font-src 'none'; connect-src 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self';
oauth-mcp-proxy includes multiple security defenses:
OAuth state parameters are protected against replay attacks:
- Timestamp validation - States expire after 10 minutes
- Nonce uniqueness - Each state uses a cryptographically random nonce
- Replay detection - Nonce tracked and rejected if reused
- Automatic cleanup - Expired nonces removed to prevent memory leaks
- Rolling deploy compatible - Accepts states from older versions during upgrades
Token caching respects JWT expiration times:
// Cache uses min(token.expiry, now + 5 minutes)
// This prevents cached tokens from being used past actual expirationRequest parameters are validated to prevent abuse:
- code parameter - Max 512 characters
- state parameter - Max 256 characters
- code_challenge parameter - Max 256 characters
- Request body size - Limited to prevent DoS (1MB for /oauth/token, 256KB for /oauth/register)
OIDC provider issuer URLs are validated:
- HTTPS required for non-localhost URLs (prevents MITM attacks)
- Valid URL format - Must parse correctly
- Not empty - Issuer must be specified
- No raw IP addresses - Hostnames only (prevents misconfiguration)
HMAC signatures verified using constant-time comparison:
// Prevents timing attacks on signature validation
hmac.Equal([]byte(receivedSig), []byte(expectedSig))Nonces generated using crypto/rand:
- Panics on failure - No fallback to weak timestamp-based nonces
- Cryptographically secure - Uses system CSPRNG
The official SDK adapter populates the go-sdk auth context:
- auth.TokenInfo populated - User ID and expiration set for session binding
- Session hijacking prevention - Requests from different users rejected
- CORS support - OPTIONS requests pass through for browser clients
Add application-level headers:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
next.ServeHTTP(w, r)
})
}
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", securityHeaders(mux))Configuration:
- All secrets in environment variables (not code)
- HTTPS enabled with valid certificates
- Audience configured and validated
- JWT secret 32+ bytes (HMAC) or provider-issued (OIDC)
- Issuer URL validated (OIDC providers)
- Redirect URIs properly configured
Built-in Security (already enabled):
- State replay protection (timestamp + nonce)
- Nonce cleanup (memory leak prevention)
- Token cache with JWT expiry awareness
- Input validation (parameter length limits)
- Request body size limits (DoS prevention)
- Constant-time HMAC comparison
- Secure nonce generation (crypto/rand)
- Security headers (CSP, X-Frame-Options, etc.)
- CORS support (OPTIONS pass-through)
Optional:
- Custom logger configured (no sensitive data logged)
- Additional rate limiting on OAuth endpoints
- Rotate secrets every 90 days
- Review OAuth provider audit logs
- Monitor for unusual authentication patterns
- Update dependencies (
go get -u) - Review token expiration policies
- Test disaster recovery (secret compromise)
If JWT secret (HMAC) leaked:
- Generate new secret immediately
- Update config and redeploy
- All existing tokens invalidated (users must re-auth)
- Review logs for suspicious activity
If client secret (OIDC) leaked:
- Revoke in OAuth provider (Okta/Google/Azure)
- Generate new secret
- Update config and redeploy
- Existing user tokens still valid (not affected)
- Multiple failed auth attempts β Consider IP blocking
- Unusual token usage patterns β Review logs
- Invalid redirect URI attempts β Security violation logged
Found a security vulnerability? Email security@[your-domain] or open a confidential GitHub Security Advisory.
Do NOT open public GitHub issues for security vulnerabilities.