By Ahmad Sadeddin

Golang Security Best Practices

A comprehensive guide to securing your Go applications

security
best-practices
golang

Introduction: Golang security best practices

Security is a key concern in today's fast-evolving digital landscape. Attackers target more and more applications every day, making it essential for developers to build software with security in mind. Even minor oversights in code can lead to system compromise, data breaches, and financial losses.

Go, also known as Golang, is widely used for building web servers, APIs, cloud services, and distributed systems due to its simplicity, concurrency support, and robust standard library. However, like any programming language, Go is not immune to security risks. Neglecting secure coding conventions can introduce issues like mismanaged inputs, weak authentication, and exposed debug information, allowing attackers to exploit them and gain a foothold in the system.

This article will guide you through many critical Golang security best practices for securing modern Go applications. These will include validating inputs, managing passwords and secrets, authentication and authorization, managing dependency, and protecting against common vulnerabilities such as SQL injection, cross-site scripting (XSS), and race conditions. You will also learn how to handle errors safely, best practices for logging, and how to leverage Go's built-in tools to identify and fix security issues early in development.

Go Security Checklist: Everything You Need to Know

Here are a few real-world security best practices that will make your Go applications more secure and help prevent many common vulnerabilities.

Input Validation and Sanitization

Validating and sanitizing any user-supplied input is critical in ensuring your Go applications' security. User input is a common attack vector, and malicious users often exploit it for SQL injection, cross-site scripting (XSS), and remote code execution attacks. Input validation and sanitization provide a first layer of defense against these attacks by ensuring your programs only accept expected data.

1. Validate User Input to Prevent Injection Attacks

You should ensure user input meets the format expected by your Go program. If the input data doesn't conform to the expected format, you can either reject it or sanitize it to match the expected format. This prevents malicious user-supplied code from reaching the execution stage of your application.

For example, if your application accepts user emails via input, you need to check if the supplied data is actually an email.

package main

import (
    "fmt"
    "regexp"
)

func isValidEmail(email string) bool {
    re := regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\$`)
    return re.MatchString(email)
}

func main() {
    email := "user@example.com"
    if !isValidEmail(email) {
        fmt.Println("Invalid email format!")
    } else {
        fmt.Println("Valid email!")
    }
}

The above code uses a regular expression to validate input emails and ensures any user-provided data follows the correct format.

2. Use Encoding Libraries to Prevent XSS Attacks

Cross-site scripting (XSS) occurs when attackers inject harmful code into pages viewed by other users of your app. When users visit these pages, the code executes, and the attack takes place. To prevent this, you need to sanitize all user-generated content before rendering it.

You can use Go’s html/template package, which automatically escapes HTML characters and prevents any injection of harmful JavaScript code into the browser DOM.

package main

import (
    "html/template"
    "os"
)

func main() {
    tmpl := `<html><body><h1>{{.}}</h1></body></html>`
    t, err := template.New("webpage").Parse(tmpl)
    if err != nil {
        panic(err)
    }

    userInput := `<script>alert("XSS")</script>` // Malicious input
    err = t.Execute(os.Stdout, userInput) // Escaped output
    if err != nil {
        panic(err)
    }
}

In this example, html/template ensures that any script tags in the userInput field are escaped, preventing potential XSS attacks.

3. Prevent Direct Execution of User-Controlled Inputs

Do not allow your program to execute any user-controlled input directly, as they can potentially lead to remote code execution and complete system compromise. You can prevent user input from directly passing into the OS and databases by employing proper validation and parameterization techniques.

For example, you can use parameterized queries to interact with databases and prevent SQL injection.

func main() {
	// Set up database connection 
	connStr := "user=username dbname=mydb sslmode=disable"
	db, err := sql.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	userInput := "john_doe"

	// Parameterized query
	query := "SELECT id, name FROM users WHERE username = \$1"
	row := db.QueryRow(query, userInput)
	
    // Database operations
}

Using parameterized queries ensures user input is treated as data, not code, preventing SQL injection.

Authentication, Authorization, and Password Management

To prevent unauthorized access and data breaches, you need to implement effective authentication and authorization layers and secure password management policies.

1. Password Hashing Using bcrypt, scrypt, or Argon2

You should never store passwords in plain text. Instead, generate password hashes using secure algorithms like bcrypt, scrypt, or Argon2. What hashing mechanism you choose depends on the severity of your applications and their associated resource cost implications. Hashing user passwords ensures they remain protected even if the database gets compromised.

package main

import (
    "fmt"
    "log"

    "golang.org/x/crypto/bcrypt"
)

func hashPassword(password string) (string, error) {
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return "", err
    }
    return string(hashedPassword), nil
}

This code snippet generates the password hash using the go bcrypt package and its GenerateFromPassword function.

2. Use JWT for Secure Token-Based Authentication

JSON Web Tokens (JWT) offer a token-based authentication mechanism between services on the internet. You can use these to authenticate different services or components of a microservice architecture statelessly. This means you don't need a token server to verify the tokens; they contain the verifying information and make service authentications straightforward.

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt/v4"
)

var secretKey = []byte("mySecretKey")

func generateJWT(userID string) (string, error) {
	claims := jwt.MapClaims{
		"user_id": userID,
		"exp":     time.Now().Add(time.Hour * 24).Unix(),
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secretKey)
}

func validateJWT(tokenStr string) (jwt.Claims, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
		return secretKey, nil
	})
	if err != nil {
		return nil, err
	}
	return token.Claims, nil
}

func main() {
	// Generate a JWT for a user
	token, err := generateJWT("12345")
	if err != nil {
		log.Fatal("Error generating JWT:", err)
	}
	fmt.Println("Generated JWT:", token)

	// Validate the JWT
	claims, err := validateJWT(token)
	if err != nil {
		log.Fatal("Error validating JWT:", err)
	}
	fmt.Println("Validated JWT Claims:", claims)
}

The above code snippets illustrate how to generate and validate a JWT using the jwt-go package.

3. Authorization with Role-Based Access Control (RBAC)

Role-Based Access Control (RBAC) is an authorization method that associates users with specific access rights. You can implement RBAC in your Go application to assign roles like admin, user, or guest that determine their level of access to various application resources.

You can either implement your own RBAC policies from scratch or utilize an existing RBAC library, such as casbin, gorbac, or topaz. The following code demonstrates a basic RBAC implementation in Go.

package main

import "fmt"

type Role string

const (
    Admin Role = "admin"
    User  Role = "user"
)

func hasAccess(role Role, resource string) bool {
    if role == Admin {
        return true
    }
    if role == User && resource == "profile" {
        return true
    }
    return false
}

In this example, hasAccess checks if the user's role grants them access to a resource. Admins have unrestricted access, while users have restricted access.

Secure Data and Network Communication

Securing your data and network communication is crucial to maintaining their integrity when in transit. Encryption provides a solid layer of defense against data interception and misuse. Leveraging secure transmission protocols and API integration methods is also essential.

1. Encrypt Sensitive Data

You must always encrypt sensitive data so that attackers can not use it even if it is compromised. There are many encryption packages to choose from, and Go's crypto package also provides powerful encryption algorithms to safeguard data.

Here is a simple, streamlined implementation of data encryption and decryption using the AES algorithm from Go's crypto library.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"log"
)

// Encrypts plainText using AES
func encrypt(plainText, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, aes.BlockSize)
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}

	stream := cipher.NewCTR(block, nonce)
	cipherText := make([]byte, len(plainText))
	stream.XORKeyStream(cipherText, plainText)

	return append(nonce, cipherText...), nil
}

// Decrypts cipherText
func decrypt(cipherText, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	nonce, cipherText := cipherText[:aes.BlockSize], cipherText[aes.BlockSize:]
	stream := cipher.NewCTR(block, nonce)

	plainText := make([]byte, len(cipherText))
	stream.XORKeyStream(plainText, cipherText)

	return plainText, nil
}

func main() {
	key := sha256.Sum256([]byte("this is a secret passphrase"))
	plainText := []byte("This is a secret message.")

	encryptedText, err := encrypt(plainText, key[:])
	if err != nil {
		log.Fatal("Error encrypting:", err)
	}
	fmt.Printf("Encrypted Text: %x\n", encryptedText)

	decryptedText, err := decrypt(encryptedText, key[:])
	if err != nil {
		log.Fatal("Error decrypting:", err)
	}
	fmt.Printf("Decrypted Text: %s\n", decryptedText)
}

For brevity, we omitted the import statements and the main function in this example.

2. Use HTTPS/TLS to Protect Data In Transit

To protect data in transit, you should use the HTTPS secure communication protocol and TLS encryption mechanism together. This will help you prevent man-in-the-middle attacks and safeguard data exposure.

The following code sets up a simple but secure HTTPS server using the http.ListenAndServeTLS method, which also requires a certificate and private key (cert.pem and key.pem).

package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Secure connection using HTTPS!")
}

func main() {
    http.HandleFunc("/", handler)
    err := http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil)
    if err != nil {
        fmt.Println("Error starting server:", err)
    }
}

This code creates a secure, encrypted communication channel for the server.

3. Secure APIs with OAuth2

OAuth2 is a robust token-based authentication standard for securing APIs. It allows protected access to resources without any credentials. Using OAuth2 for authentication binds access rights to an API, enabling your Go app to authorize access to APIs on behalf of users through access tokens.

Here is a simple Go implementation of using OAuth2 for API authentication.

package main

import (
    "fmt"
    "log"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
    "net/http"

)

func getGoogleDriveFiles(clientID, clientSecret, accessToken string) (*http.Response, error) {
    // OAuth2 configuration for Google API
    conf := &oauth2.Config{
        ClientID:     clientID,
        ClientSecret: clientSecret,
        Scopes:       []string{"https://www.googleapis.com/auth/drive.readonly"},
        RedirectURL:  "http://localhost:8080/callback",
        Endpoint:     google.Endpoint,
    }

    // Create an OAuth2 token
    token := &oauth2.Token{AccessToken: accessToken}

    // Use the token to create an HTTP client
    client := conf.Client(oauth2.NoContext, token)

    // Access Google API using the client
    res, err := client.Get("https://www.googleapis.com/drive/v3/files")
    return res, err
}

func main() {
    clientID := "your-client-id"
    clientSecret := "your-client-secret"
    accessToken := "your-access-token"

    res, err := getGoogleDriveFiles(clientID, clientSecret, accessToken)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()
}

We omitted the import statements here for brevity.

Secure Session and Token Management

A lack of security controls can allow attackers to control user sessions and compromise your Go application. Sessions and tokens need to be protected diligently to ensure system integrity and prevent malicious access.

1. Set Token Expiration to Avoid Long-Lived Sessions

Attackers often exploit long-lived user sessions, so time-limiting each session reduces the risk of session hijacking. To avoid long-lived sessions, you can implement robust token expiration and refresh policies.

For example, the following code generates a JWT with a 1-hour expiration period, ensuring users need to re-authenticate after this period.

var secretKey = []byte("your-secret-key")

func generateJWT(userID string) (string, error) {
    claims := jwt.MapClaims{
        "user_id": userID,
        "exp":     time.Now().Add(time.Hour * 1).Unix(), // Expiration time (1 hour)
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secretKey)
}

Import statements and the main function are omitted to maintain conciseness.

2. Secure Session Cookies With Appropriate Flags and Attributes

You should also always use HTTPS to transmit session data and set the session cookies flags as secure.

http.SetCookie(w, &http.Cookie{
    Name:     "session_id",
    Value:    sessionID,
    Path:     "/",
    HttpOnly: true,
    Secure:   true,
    SameSite: http.SameSiteStrictMode,
})

Setting Secure = true ensures your application only sends cookies over HTTPS, and HttpOnly = true prevents client-side scripts from accessing the cookie, preventing potential XSS attacks. The SameSite attribute restricts how cookies are sent with cross-site requests and offers a valuable safeguard against Cross-Site Request Forgery (CSRF) attacks.

Error Handling and Logging

Proper error handling and logging are crucial for identifying and resolving underlying issues in your Go application. They help detect potential breaches and help pinpoint operational deficiencies.

1. Don't Expose Debug Information in Production

Hackers often leverage exposed error messages, stack traces, or similar debug information to find flaws in your system and gain an initial entry point into the application. This is why your Go application must never expose this information in production. You also need to conceal sensitive database queries, internal configurations, and data paths to reduce the attack surface for malicious users.

The below example shows how to gracefully handle errors and avoid revealing sensitive data in production code.

func main() {
    _, err := os.Open("nonexistent_file.txt")
    if err != nil {
        // Log detailed error for internal use only
        log.Println("Error opening file:", err)

        // Return generic error message to the user
        fmt.Println("An error occurred, please try again later.")
    }
}

This example logs detailed error messages for internal use but only returns generic information to the app's end user.

2. Mask Sensitive Data in Logs

When logging data, it’s essential to mask or avoid logging sensitive information like passwords, credit card numbers, or API keys. You can prevent accidental leaks of sensitive data into logs this way.

The following code snippet demonstrates this in action.

func logSensitiveData(data string) {
    maskedData := strings.Repeat("*", len(data)) // Mask the sensitive data
    log.Println("Sensitive data received:", maskedData)
}

func main() {
    sensitiveData := "password123"
    logSensitiveData(sensitiveData) // Logs masked data
}

The above code shows how to mask sensitive information, such as user passwords, before storing them in the logging backend.

3. Use Structured Logging Libraries

Structured logging is essential for an organized and easily queryable log system. You can leverage various Go logging libraries, such as Logrus, Zap, and Zerolog, to store your app's log data in a structured and accessible format.

The code snippet below shows an example of using Logrus for structured logging.

package main

import (
    "github.com/sirupsen/logrus"
)

func main() {
    log := logrus.New()

    log.WithFields(logrus.Fields{
        "user_id": 12345,
        "event":   "login",
    }).Info("User logged in")

    log.Error("An error occurred")
}

In this example, we're logging structured data with different attributes to make the logs more informative and helpful for debugging.

Code Review and Static Analysis

Regular code review and binary analysis of your Go application can help detect bugs before attackers can exploit them. Thus, it is essential to implement regular code audits and incorporate penetration testing and fuzzing to uncover weak links in your codebase.

1. Scan Source Code and Binaries for Vulnerabilities

You can detect a wide range of vulnerabilities, including SQL injection, cross-site scripting (XSS), and insecure deserialization in your codebase, using static analysis tools like Corgea and govulncheck. These tools leverage various vulnerability databases and threat patterns to identify potential issues early in development.

Corgea, a modern SAST application, offers a full-fledged security platform that leverages AI to identify security gaps and offers robust integration with existing tools. Meanwhile, govulncheck is a CLI utility that can find and fix many common Go vulnerabilities directly from the terminal. You should also use code linting tools like golangci-lint or staticcheck to identify common issues in your Go code.

2. Regularly Audit Codebase and Perform Penetration Testing

Regular security audits can find problems in your code that automated tools might have missed. You can do the audits yourself or hire an outside service for an independent evaluation.

Penetration testing is the practice of breaking an application to find issues that hackers can exploit in production. Combining penetration testing with regular audits and SAST analysis can uncover hard-to-detect vulnerabilities and significantly reduce the attack surface of your Go application.

You should also leverage various Go fuzzing tools and techniques to identify edge cases in your program. Attackers often rely on fuzzing to detect these edge scenarios that may lead to issues like memory leaks, buffer overflows, and race conditions. These are difficult to catch using standard tools and can open up your application to a plethora of attack vectors.

Secure Dependency and Package Management

Dependency and package management need to be secure to prevent supply chain attacks and to ensure your Go application remains up-to-date.

1. Use Dependency Management Tools

Go has an in-built dependency management tool, go mod, which handles package management by simplifying versioning and ensuring your project uses the specific versions of packages it needs, eliminating version drifts.

go mod init myproject
go get github.com/some/package

Using the go mod tool creates a go.mod file that tracks and locks dependencies for your projects. It makes sure sudden changes in a package do not break your existing application.

2. Update Dependencies to Patch Security Vulnerabilities

Over time, security vulnerabilities may get introduced to third-party libraries used by your application. You need to update these to a new, patched version to keep your Go application safe from malicious attackers.

You can use the go get command to update your package dependencies,

go get -u github.com/some/package

You may also leverage tools like Dependabot if you're hosting your Go application on GitHub. This tool will help you stay on track with your project dependencies. By keeping your dependencies updated, you protect your application from known security flaws in outdated packages.

3. Verify Third-Party Packages

Supply chain attacks involve breaching your application by first exploiting a trusted third-party package. You need to verify all such packages used in your program to thwart these attacks. You can do this by manually reviewing the source code of a third-party library, taking a look at its maintainers, and using tools that check for known vulnerabilities in Go application dependencies, such as govulncheck and go-sec.

Concurrency Safety in Go

Go implements native concurrency support through its use of channels and goroutines. This concurrency support makes handling parallel jobs easier but can lead to issues like race conditions and data corruption if not appropriately handled.

1. Check Race Conditions Using Go’s Race Detector

Race conditions occur when multiple goroutines try to access shared data concurrently while at least one of them tries to modify it. Go comes with a built-in race detector to help developers identify these race conditions early in development.

go test -race

Running the above command will test your Go application for race conditions, helping fix any issues before hackers can exploit them.

2. Use Mutexes and Channels for Safe Concurrency

Mutexes and channels are synchronization mechanisms provided by Go to handle concurrent access to shared data safely. A mutex is a lock that prevents other goroutines from accessing or modifying shared data while another goroutine is using it. Channels are communication mechanisms through which goroutines talk to each other and pass data safely.

The following simple example illustrates the use of mutexes and goroutines in handling shared data.

package main

import (
	"fmt"
	"sync"
)

var counter int
var mu sync.Mutex

func increment() {
	mu.Lock() // Lock the mutex
	defer mu.Unlock()
	counter++
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			increment()
		}()
	}
	wg.Wait() // Wait for all goroutines to finish
	fmt.Println("Final counter value:", counter)
}

Channels also provide a convenient alternative to shared variables for transferring data between different goroutines, keeping your Go program safe from data corruption. The example below shows how channels can transfer data between goroutines without requiring mutex locks.

func sendData(ch chan<- int) {
	ch <- 42 // Send data to the channel
}

func receiveData(ch <-chan int) {
	data := <-ch // Receive data from the channel
	fmt.Println("Received data:", data)
}

func main() {
	ch := make(chan int)
	go sendData(ch)
	receiveData(ch)
}

Secure Coding Practices in Go

In addition to the above-mentioned security best practices for Go, you should also embrace safe coding practices to make your Go application bulletproof.

1. Use Strong Typing to Avoid Unintended Errors

Go offers strong typing through which you define variables explicitly or initialize a value so Go can infer its type. This practice helps avoid unexpected errors arising from type mismatches and increases the clarity of your code.

func add(a int, b int) int {
	return a + b
}

func main() {
	result := add(5, 3) // Correctly typed arguments
	fmt.Println(result)
}

This example clearly defines the variables as integers and prevents the possibility of mixing types like int and string.

2. Use the defer Keyword

Go's defer keyword ensures that your application correctly cleans up resources like files, networks, or database connections when they are no longer needed.

func main() {
	file, err := os.Open("example.txt")
	if err != nil {
		fmt.Println("Error opening file:", err)
		return
	}
	defer file.Close() // Ensures the file is closed when the function exits

	// File operations here...
}

Using defer this way helps prevent dangling resources and avert resource leaks in your Go program.

3. Do Not Discard Error Messages

Go allows developers to discard error messages using the blank "_" identifier. However, doing this repeatedly across your code can lead to missing critical issues, making it harder to diagnose problems. So, stop discarding error messages this way and instead handle them properly.

func main() {
	_, err := os.Open("nonexistent.txt")
	if err != nil {
		// Log the error with details for the developer
		log.Printf("Error opening file: %v", err)

		// Provide a generic message to the user
		fmt.Println("Sorry, something went wrong. Please try again later.")
		return
	}
}

Conclusion

Go is a reliable programming language for building modern web applications and services. However, bad coding practices can introduce problems in your Go code, which malicious attackers might exploit to break your application. This guide provided a checklist of best security practices in Go that can make your applications inherently secure and hard to break.

You've learned how to tackle issues like input validation and sanitization, authentication and access controls, session management, and concurrency safety. In addition to these, using automated SAST tools like Corgea is also a must for serious Go developers. By following the best practices outlined in this guide, you can confidently build your next Go application and focus on developing features instead of worrying about code security.