12 KiB
Lesson 9 — Rate Limiting & Security Hardening
New Go concepts in this lesson: almost none new at the language level — this lesson is mostly about correctly configuring existing tools (
httprate,cors, cookie flags) rather than new syntax. A good lesson to consolidate everything from Go Basics so far.
Four separate concerns, each small on its own: rate limiting (stop abuse/brute-force), secure cookie flags (protect the session cookie itself), CORS (control which websites can call your API from a browser), and a basic CSRF mitigation for our cookie-based sessions.
Part A — standalone playgrounds
1. Rate limiting with httprate
mkdir ~/go-playground/security-demo && cd ~/go-playground/security-demo
go mod init security-demo
go get github.com/go-chi/httprate@latest
go get github.com/go-chi/chi/v5@latest
main.go
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
)
func main() {
r := chi.NewRouter()
// 1. Limit EVERY client to 5 requests per 10 seconds, keyed by IP.
r.Use(httprate.LimitByIP(5, 10*time.Second))
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "pong")
})
log.Println("listening on :4000")
log.Fatal(http.ListenAndServe(":4000", r))
}
Run it and hammer it:
go run .
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/ping; done
You should see 200 five times, then 429 (Too Many Requests) for the
rest, until 10 seconds pass.
httprate.LimitByIP(5, 10*time.Second)— a ready-made middleware (samefunc(http.Handler) http.Handlershape you already know) tracking request counts per client IP, in a sliding window. Exceeding the limit auto-responds with429 Too Many Requests— you don't write that logic yourself.- Why keyed by IP: without a key, one abusive client could exhaust the
"budget" for every other user too.
LimitByIPisolates each caller's own quota. (Other keying strategies exist too —LimitByRealIP, or custom keys like "by user ID" once authenticated.) This matters most on/loginand/register— without it, someone could script thousands of password guesses per second against/login.
2. Cookie security flags
No need to run this one — just understand each flag, since we set these
on scs's cookie config (already partly done in Lesson 6), not by hand:
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "abc123",
Path: "/",
HttpOnly: true, // JS cannot read this cookie
Secure: true, // browser only sends it over HTTPS
SameSite: http.SameSiteLaxMode, // restricts cross-site sending
})
HttpOnly: true— blocksdocument.cookieaccess from JavaScript. Defeats a whole class of XSS attacks that try to steal the session cookie via injected script.Secure: true— the browser will refuse to send this cookie over plain HTTP, only HTTPS. Important gotcha: if you set this while developing locally overhttp://localhost, the cookie won't be sent at all — you'll be confused why sessions "don't work." We'll make this environment-dependent in Part B.SameSite: http.SameSiteLaxMode— controls whether the cookie is sent on cross-site requests.Lax(a good default) sends the cookie on top-level navigations (clicking a link to your site) but not on cross-sitePOSTs triggered by another page (like a malicious<form>auto-submitting to your/logout) — this is your main defense against CSRF for cookie-based auth.Strictis even tighter but can break legitimate cross-site navigation flows (like our own OAuth callback from Google!).Nonedisables the protection entirely and requiresSecure: true.
3. CORS
CORS only matters for requests made from browser JavaScript running on
a different origin than your API (e.g., a React app on
http://localhost:3000 calling your API on http://localhost:8080). It
does not protect your API from curl, mobile apps, or server-to-server
calls — CORS is a browser-enforced rule, not a server-side security
boundary. It controls which websites a browser will let call your API
with the user's cookies/credentials attached.
go get github.com/go-chi/cors@latest
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"}, // your frontend's origin
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true, // required for cookies to be sent cross-origin
}))
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
http.ListenAndServe(":4000", r)
}
AllowedOrigins— an explicit allowlist. Never use"*"(wildcard) together withAllowCredentials: true— browsers actually forbid that combination outright, and even without credentials it's a bad default for anything handling auth.AllowCredentials: true— without this, the browser won't include cookies on cross-origin requests to your API at all, so session-based auth from a separate frontend wouldn't work.
Part B — apply it all to the project
Get the dependencies:
go get github.com/go-chi/httprate@latest
go get github.com/go-chi/cors@latest
Update internal/router/router.go — apply a general limit to
everything, and a stricter one specifically to auth endpoints:
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true,
}))
// A generous global limit - mostly to stop runaway scripts/bots.
r.Use(httprate.LimitByIP(100, time.Minute))
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
// A much stricter limit specifically on login/register, since these
// are exactly what a credential-stuffing / brute-force script targets.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(5, time.Minute))
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
})
r.Post("/logout", authHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", authHandler.Me)
})
googleConfig := oauth.NewGoogleConfig(cfg)
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
r.Get("/auth/google/login", googleHandler.Login)
r.Get("/auth/google/callback", googleHandler.Callback)
return r
}
- Two separate
httprate.LimitByIPcalls at different scopes — the global100/minuteis a loose safety net for the whole API, while ther.Grouparound/registerand/loginlayers a much tighter5/minuteon top. Both limits apply simultaneously to requests inside the group (they stack). /logoutdeliberately sits outside that strict group — you don't want to rate-limit a legitimate logged-in user trying to log out.cors.Handler(...)now readscfg.AllowedOriginsinstead of a hardcoded value.
Extend internal/config/config.go for CORS origins and cookie
security:
import "strings"
type Config struct {
Port string
Env string // "development" or "production"
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
RedisAddr string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
AllowedOrigins []string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
Env: getEnv("ENV", "development"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
}
}
Env— distinguishes development from production, used next for the cookie'sSecureflag.strings.Split(getEnv(...), ",")— lets you configure multiple allowed origins via one comma-separated env var (see Go Basics Part 3 on slices), e.g.ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com.
Update internal/session/session.go — make Secure environment-aware,
fixing the localhost gotcha from Part A:
package session
import (
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
func New(cfg config.Config) *scs.SessionManager {
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", cfg.RedisAddr)
},
}
manager := scs.New()
manager.Store = redisstore.New(pool)
manager.Lifetime = 24 * time.Hour
manager.Cookie.Name = "session_id"
manager.Cookie.HttpOnly = true
manager.Cookie.SameSite = http.SameSiteLaxMode
manager.Cookie.Secure = cfg.Env == "production" // only require HTTPS in prod
return manager
}
manager.Cookie.Secure = cfg.Env == "production" — in development
(ENV unset or "development"), the cookie works over plain
http://localhost. In production, set ENV=production and the cookie
will refuse to be sent over anything but HTTPS.
Update cmd/api/main.go — no change needed; router.New(logger, db, sessions, cfg) already passes cfg, which now carries AllowedOrigins
and Env.
Add to your .env:
ENV=development
ALLOWED_ORIGINS=http://localhost:3000
Try it
go run ./cmd/api
Rate limiting:
for i in $(seq 1 7); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"nope@example.com","password":"wrong"}'
done
You should see 401 (wrong credentials) for the first 5, then 429
(rate limited) for the rest.
CORS:
curl -i -X OPTIONS http://localhost:8080/login \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST"
Look for Access-Control-Allow-Origin: http://localhost:3000 in the
response headers.
A note on what we're not doing yet: full CSRF-token-based protection (a
token embedded in forms and checked server-side) is a deeper topic on its
own, and SameSite=Lax already covers the most common cookie-based CSRF
vector for a JSON API like this. If you later build a traditional
HTML-form frontend served from the same origin, that's when a dedicated
CSRF token library becomes worth adding — treat current protections as
sufficient for this course's scope.
Once rate limiting and CORS both check out, move to Lesson 10 — Docker, docker-compose, and the full course wrap-up.