Securing Go APIs: OWASP Best Practices

Security isn’t an afterthought - it’s a fundamental requirement for any production API. This post covers the OWASP API Security Top 10 vulnerabilities and practical Go implementations to protect your applications.

OWASP API Security Top 10

flowchart TD
    subgraph Top10["OWASP API Security Top 10"]
        A1[1. Broken Object Level Auth]
        A2[2. Broken Authentication]
        A3[3. Broken Object Property Auth]
        A4[4. Unrestricted Resource Consumption]
        A5[5. Broken Function Level Auth]
        A6[6. Mass Assignment]
        A7[7. Security Misconfiguration]
        A8[8. Injection]
        A9[9. Improper Asset Management]
        A10[10. Unsafe API Consumption]
    end

    style Top10 fill:#ffcdd2

SQL Injection Prevention

The Vulnerability

1
2
3
4
5
6
7
8
// VULNERABLE - Never do this!
func GetUser(db *sql.DB, username string) (*User, error) {
query := "SELECT * FROM users WHERE username = '" + username + "'"
// Attacker input: ' OR '1'='1
// Results in: SELECT * FROM users WHERE username = '' OR '1'='1'
row := db.QueryRow(query)
// ...
}

Secure Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Using parameterized queries (GORM)
func GetUser(db *gorm.DB, username string) (*User, error) {
var user User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}

// Using parameterized queries (database/sql)
func GetUserSQL(db *sql.DB, username string) (*User, error) {
query := "SELECT id, username, email FROM users WHERE username = $1"
row := db.QueryRow(query, username)

var user User
if err := row.Scan(&user.ID, &user.Username, &user.Email); err != nil {
return nil, err
}
return &user, nil
}

// Safe dynamic queries with validation
func SearchProducts(db *gorm.DB, filters map[string]string) ([]Product, error) {
allowedFields := map[string]bool{
"name": true,
"category": true,
"status": true,
}

query := db.Model(&Product{})

for field, value := range filters {
if !allowedFields[field] {
continue // Skip unknown fields
}
query = query.Where(field+" = ?", value)
}

var products []Product
return products, query.Find(&products).Error
}

Input Validation

Validation Middleware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import (
"github.com/go-playground/validator/v10"
)

type CreateUserRequest struct {
Username string `json:"username" validate:"required,min=3,max=50,alphanum"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,containsany=!@#$%"`
Age int `json:"age" validate:"omitempty,min=13,max=120"`
}

var validate = validator.New()

func ValidateRequest(req interface{}) error {
if err := validate.Struct(req); err != nil {
var validationErrors []string
for _, err := range err.(validator.ValidationErrors) {
validationErrors = append(validationErrors,
fmt.Sprintf("%s: %s", err.Field(), err.Tag()))
}
return fmt.Errorf("validation failed: %v", validationErrors)
}
return nil
}

// Sanitize input
func SanitizeString(input string) string {
// Remove potential XSS characters
input = html.EscapeString(input)
// Trim whitespace
input = strings.TrimSpace(input)
return input
}

Content Type Validation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func ContentTypeMiddleware(allowedTypes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
contentType := c.ContentType()

allowed := false
for _, t := range allowedTypes {
if strings.HasPrefix(contentType, t) {
allowed = true
break
}
}

if !allowed {
c.JSON(http.StatusUnsupportedMediaType, gin.H{
"error": "Content-Type not allowed",
})
c.Abort()
return
}
}
c.Next()
}
}

// Usage
router.Use(ContentTypeMiddleware("application/json"))

Mass Assignment Protection

The Vulnerability

1
2
3
4
5
6
7
8
9
// VULNERABLE - Allows attackers to modify any field
func UpdateUser(c *gin.Context) {
var user User
db.First(&user, c.Param("id"))

// Attacker could send: {"role": "admin", "is_active": false}
c.ShouldBindJSON(&user)
db.Save(&user)
}

Secure Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Define explicit update DTOs
type UpdateUserRequest struct {
Username string `json:"username" binding:"omitempty,min=3,max=50"`
Email string `json:"email" binding:"omitempty,email"`
Bio string `json:"bio" binding:"omitempty,max=500"`
// Note: role and is_active are NOT included
}

func UpdateUser(c *gin.Context) {
id := c.Param("id")

var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Only update allowed fields
updates := map[string]interface{}{}
if req.Username != "" {
updates["username"] = req.Username
}
if req.Email != "" {
updates["email"] = req.Email
}
if req.Bio != "" {
updates["bio"] = req.Bio
}

if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}

if err := db.Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Update failed"})
return
}

c.JSON(http.StatusOK, gin.H{"message": "Updated successfully"})
}

Security Headers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func SecurityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")

// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")

// Enable XSS filter
c.Header("X-XSS-Protection", "1; mode=block")

// Strict Transport Security
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")

// Content Security Policy
c.Header("Content-Security-Policy", "default-src 'self'")

// Referrer Policy
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")

// Permissions Policy
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")

c.Next()
}
}

Rate Limiting

Token Bucket with Redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import (
"context"
"time"

"github.com/redis/go-redis/v9"
)

type RateLimiter struct {
client *redis.Client
rate int // Requests per window
window time.Duration // Window size
}

func NewRateLimiter(client *redis.Client, rate int, window time.Duration) *RateLimiter {
return &RateLimiter{
client: client,
rate: rate,
window: window,
}
}

func (rl *RateLimiter) Allow(ctx context.Context, key string) (bool, int, error) {
now := time.Now()
windowStart := now.Truncate(rl.window)
redisKey := fmt.Sprintf("ratelimit:%s:%d", key, windowStart.Unix())

pipe := rl.client.Pipeline()
incr := pipe.Incr(ctx, redisKey)
pipe.Expire(ctx, redisKey, rl.window)
_, err := pipe.Exec(ctx)

if err != nil {
return false, 0, err
}

count := int(incr.Val())
remaining := rl.rate - count
if remaining < 0 {
remaining = 0
}

return count <= rl.rate, remaining, nil
}

func RateLimitMiddleware(limiter *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.ClientIP() // Or use API key, user ID

allowed, remaining, err := limiter.Allow(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Rate limit error"})
c.Abort()
return
}

c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Header("X-RateLimit-Limit", strconv.Itoa(limiter.rate))

if !allowed {
c.Header("Retry-After", strconv.Itoa(int(limiter.window.Seconds())))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Rate limit exceeded",
})
c.Abort()
return
}

c.Next()
}
}

Structured Logging

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

func InitLogger() *zap.Logger {
config := zap.Config{
Level: zap.NewAtomicLevelAt(zap.InfoLevel),
Development: false,
Encoding: "json",
EncoderConfig: zapcore.EncoderConfig{
TimeKey: "timestamp",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "message",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
},
OutputPaths: []string{"stdout"},
ErrorOutputPaths: []string{"stderr"},
}

logger, _ := config.Build()
return logger
}

func LoggingMiddleware(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery

c.Next()

latency := time.Since(start)

// Security: Don't log sensitive data
fields := []zap.Field{
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("ip", c.ClientIP()),
zap.Duration("latency", latency),
zap.String("user_agent", c.Request.UserAgent()),
}

if query != "" {
// Sanitize query params (remove tokens, passwords)
fields = append(fields, zap.String("query", sanitizeQuery(query)))
}

if requestID := c.GetString("request_id"); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}

if userID := c.GetUint("user_id"); userID > 0 {
fields = append(fields, zap.Uint("user_id", userID))
}

if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
logger.Error("Request failed", fields...)
} else {
logger.Info("Request completed", fields...)
}
}
}

func sanitizeQuery(query string) string {
// Remove sensitive parameters
sensitiveParams := []string{"password", "token", "api_key", "secret"}
for _, param := range sensitiveParams {
re := regexp.MustCompile(fmt.Sprintf(`(%s=)[^&]*`, param))
query = re.ReplaceAllString(query, "${1}[REDACTED]")
}
return query
}

Security Audit Logging

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type AuditLog struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Status string `json:"status"` // success, failure
Details string `json:"details" gorm:"type:jsonb"`
CreatedAt time.Time `json:"created_at"`
}

type AuditLogger struct {
db *gorm.DB
}

func (a *AuditLogger) Log(c *gin.Context, action, resource, resourceID, status string, details map[string]interface{}) {
detailsJSON, _ := json.Marshal(details)

log := AuditLog{
UserID: c.GetUint("user_id"),
Action: action,
Resource: resource,
ResourceID: resourceID,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Status: status,
Details: string(detailsJSON),
}

go a.db.Create(&log) // Async logging
}

// Usage in handlers
func (h *Handler) DeleteUser(c *gin.Context) {
userID := c.Param("id")

if err := h.userRepo.Delete(userID); err != nil {
h.audit.Log(c, "delete", "user", userID, "failure", map[string]interface{}{
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Delete failed"})
return
}

h.audit.Log(c, "delete", "user", userID, "success", nil)
c.Status(http.StatusNoContent)
}

HTTPS and TLS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import (
"crypto/tls"
)

func main() {
router := gin.Default()
// ... setup routes ...

// TLS configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
}

server := &http.Server{
Addr: ":443",
Handler: router,
TLSConfig: tlsConfig,
}

// HTTP to HTTPS redirect
go func() {
redirectServer := &http.Server{
Addr: ":80",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := "https://" + r.Host + r.URL.Path
http.Redirect(w, r, target, http.StatusMovedPermanently)
}),
}
redirectServer.ListenAndServe()
}()

log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

Security Checklist

flowchart TD
    subgraph Input["Input Security"]
        I1[Parameterized Queries]
        I2[Input Validation]
        I3[Content-Type Check]
    end

    subgraph Auth["Authentication"]
        A1[Strong Password Hash]
        A2[JWT with Short Expiry]
        A3[Secure Token Storage]
    end

    subgraph Access["Access Control"]
        AC1[RBAC/ABAC]
        AC2[Resource Ownership]
        AC3[Mass Assignment Protection]
    end

    subgraph Monitor["Monitoring"]
        M1[Structured Logging]
        M2[Audit Trail]
        M3[Rate Limiting]
    end

    style Input fill:#e3f2fd
    style Auth fill:#fff3e0
    style Access fill:#e8f5e9
    style Monitor fill:#fce4ec

Summary

Area Best Practices
Injection Parameterized queries, input validation
Authentication bcrypt, JWT, secure token storage
Authorization RBAC/ABAC, ownership checks
Data Protection Mass assignment prevention, field whitelisting
Transport HTTPS only, TLS 1.2+
Headers Security headers (CSP, HSTS, X-Frame-Options)
Monitoring Structured logging, audit trails
Rate Limiting Token bucket, per-client limits

This concludes the Go Backend Development series. For the complete learning path, see the Series Overview.

Authorization Patterns: RBAC and ABAC in Go TIL: Setup MCP Server in Claude Code

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×