# 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` ```bash 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`** ```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: ```bash 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 (same `func(http.Handler) http.Handler` shape you already know) tracking request counts **per client IP**, in a sliding window. Exceeding the limit auto-responds with `429 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. `LimitByIP` isolates each caller's own quota. (Other keying strategies exist too — `LimitByRealIP`, or custom keys like "by user ID" once authenticated.) This matters most on `/login` and `/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: ```go 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` — blocks `document.cookie` access 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 over `http://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-site `POST`s triggered by another page (like a malicious `
` auto-submitting to your `/logout`) — this is your main defense against CSRF for cookie-based auth. `Strict` is even tighter but can break legitimate cross-site navigation flows (like our own OAuth callback from Google!). `None` disables the protection entirely and requires `Secure: 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. ```bash go get github.com/go-chi/cors@latest ``` ```go 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 with `AllowCredentials: 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:** ```bash 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: ```go 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.LimitByIP` calls at different scopes — the global `100/minute` is a loose safety net for the whole API, while the `r.Group` around `/register` and `/login` layers a *much* tighter `5/minute` on top. Both limits apply simultaneously to requests inside the group (they stack). - `/logout` deliberately sits *outside* that strict group — you don't want to rate-limit a legitimate logged-in user trying to log out. - `cors.Handler(...)` now reads `cfg.AllowedOrigins` instead of a hardcoded value. **Extend `internal/config/config.go`** for CORS origins and cookie security: ```go 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's `Secure` flag. - `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: ```go 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 ```bash go run ./cmd/api ``` **Rate limiting:** ```bash 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:** ```bash 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.