Files
go-simple-api/lessons/lesson-09-rate-limiting-security.md
T
2026-07-16 10:13:46 +03:30

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 (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.

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 — 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 POSTs triggered by another page (like a malicious <form> 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.

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 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:

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.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:

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:

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.