This episode secures the transport and input layers: configuring trusted proxies so ClientIP can't be spoofed, TLS with ListenAndServeTLS, nginx and caddy reverse proxy integration, safe pagination, and injection and SSRF prevention.

Behind the scenes, production applications rarely receive requests directly from clients. There's a proxy, load balancer, or CDN in front. This episode 15 dissects HTTPS, trusted proxies & input hardening: how to trust the right proxies, run TLS, integrate with nginx and caddy reverse proxies, and harden input so the application is resistant to injection and SSRF.
Two classic problems arise if this part is ignored: client IPs that can be spoofed through the X-Forwarded-For header, and user input used directly in queries or URLs. Both lead to attacks that could be prevented with small configuration habits.
By default, Gin trusts all proxies. As a result, c.ClientIP() can be spoofed by anyone:
curl -H "X-Forwarded-For: 203.0.113.66" http://localhost:8080/apic.ClientIP() will return 203.0.113.66 because that header is trusted. The per-IP rate limiter from episodes 12-14 will be fooled, and audit logs will record the wrong IP.
State explicitly which proxies may fill in that header:
err := r.SetTrustedProxies([]string{
"127.0.0.1",
"10.0.0.0/8",
"172.16.0.0/12",
})
if err != nil {
log.Fatal(err)
}r.SetTrustedProxies([]string{"127.0.0.1", ...}) only trusts the listed IPs and CIDR ranges. Requests coming from outside the list won't have their X-Forwarded-For headers trusted. If you don't use any proxy at all, call r.SetTrustedProxies(nil) so ClientIP always takes the direct socket address.
If Gin handles TLS itself, use RunTLS:
r.RunTLS(":8443", "./certs/server.crt", "./certs/server.key")r.RunTLS(":8443", "server.crt", "server.key") runs the server with a certificate and private key. Certificates can be obtained free from Let's Encrypt or supplied from an internal CA. In production, make sure certificate files have strict permissions and never enter the repository.
A common production pattern: nginx handles TLS and HTTP/2, then forwards to Gin on an internal port. The nginx configuration:
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}proxy_pass http://127.0.0.1:8080 forwards requests to Gin. nginx adds X-Forwarded-For and X-Forwarded-Proto; therefore, on the Gin side, SetTrustedProxies must list 127.0.0.1 so those headers are trusted — and only that.
Caddy is much simpler because it manages certificates automatically:
api.example.com {
reverse_proxy localhost:8080
}The block reverse_proxy localhost:8080 is enough to turn on HTTPS with automatic certificates. Choose nginx if you need granular control and a wide plugin ecosystem; choose caddy for simplicity and managed certificates.
Safe pagination limits how much data can be requested at once. Never use a raw query for LIMIT:
func paginate(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
offset := (page - 1) * limit
return limit, offset
}c.DefaultQuery("page", "1") reads the query with a fallback. The upper bound limit > 100 prevents clients from requesting ?limit=999999, which could drain the database. Values are forced into a safe range before being used in a query.
users, err := repo.List(ctx, gorm.Clause{Limit: limit, Offset: offset})gorm.Clause{Limit: limit, Offset: offset} limits results on the database side. With validated input, this clause never carries wild values.
SQL injection happens when input is concatenated raw into a query string. Always use placeholders:
rows, err := db.Query(ctx,
"SELECT id, name FROM users WHERE email = $1", email)db.Query(ctx, "... WHERE email = $1", email) sends the value as a separate parameter, not as part of the SQL. With GORM, the form Where("email = ?", email) is equally safe. The golden rule: never concatenate user input into SQL.
SSRF happens when an application loads a URL provided by the user. Validate the protocol and host before calling:
func safeFetch(rawURL string) (*http.Response, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("skema URL tidak diizinkan")
}
ip := net.ParseIP(u.Hostname())
if ip != nil && ip.IsPrivate() {
return nil, fmt.Errorf("host privat tidak diizinkan")
}
return http.Get(u.String())
}ip.IsPrivate() rejects targets on internal networks like 127.0.0.1 or 10.0.0.1. Combine it with scheme validation, an allowlist of permitted hosts, and a timeout to fully close off SSRF paths.
Key takeaways:
SetTrustedProxies with an explicit list prevents ClientIP spoofing.RunTLS for direct TLS; nginx or caddy for TLS termination in front.X-Forwarded-Proto should be trusted only from legitimate proxies.page and limit into safe ranges.In the next episode, episode 16, we'll dissect WebSocket, streaming & SSE — realtime connections with gorilla/websocket in Gin handlers, Server-Sent Events with c.SSEvent, streaming uploads and downloads, and notification and chat use cases.