This guide outlines security best practices when using oauth-mcp-proxy in production.
β 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},
})import "golang.org/x/time/rate"
limiter := 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 !limiter.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
X-XSS-Protection: 1; mode=block
Cache-Control: no-store (for sensitive endpoints)
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))- 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)
- Redirect URIs properly configured
- Token expiration set appropriately
- Custom logger configured (no sensitive data logged)
- Rate limiting on OAuth endpoints
- Security headers configured
- 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.