Building a REST API with Go
Have you ever wondered how modern web applications handle user authentication and data securely? In this guide, we’ll explore building a secure REST API from the ground up using Go. Whether you’re new to Go or an experienced developer, you’ll learn how to create an API that handles user authentication.
For those who need to see the whole code before diving in, you can find all of this on Github
Technology Stack Overview
Our API implementation uses three main technologies:
- Go: A programming language designed for building efficient, secure network services with built-in concurrency support (My language of choice).
- Gin: A high-performance web framework for Go that provides excellent routing capabilities and middleware support.
- SQLite: A lightweight database that’s ideal for development and applications with moderate traffic requirements.
Understanding Our Project Structure
The project uses a standard Go application layout that separates concerns for maintainability:
myapp/
|-- main.go
|-- db/
| |-- connection.go
| |-- user.go
|-- handlers/
| |-- auth.go
| |-- signup.go
| |-- login.go
| |-- user.go
|-- internal/
| |-- jwt/
| | |-- token.go
| | |-- middleware.go
|-- models/
| |-- user.go
| |-- errors.go
I like to separate domain specific logic. That way when another engineer starts exploring the repository, they have some general idea where logic exists.
db
-> database logic
handlers
-> api handlers
internal
-> contains repository specific packages (this is idiomatic Go)
models
-> contains my data models
Setting Up Your Development Environment
First, let’s gather our ingredients (dependencies). Open your terminal and run:
# Create a new Go project
go mod init myapp
# Install our essential tools
go get github.com/gin-gonic/gin # Our web framework
go get github.com/mattn/go-sqlite3 # Database driver
go get github.com/golang-jwt/jwt/v4 # For secure user tokens
Define the Model
Create the models
directory.
mkdir -p models
Next we will start creating go files under models
. The user model will be defined in the models/user.go
.
models/user.go
package models
import (
"encoding/json"
"time"
)
type User struct {
ID int64 `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"` // The "-" tag prevents password from being included in JSON
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// NewUser creates a new user with the current timestamp
func NewUser(username, password string) *User {
now := time.Now()
return &User{
Username: username,
Password: password,
CreatedAt: now,
UpdatedAt: now,
}
}
// ValidateForCreation performs basic validation for user creation
func (u *User) ValidateForCreation() error {
if len(u.Username) < 3 {
return ErrUsernameTooShort
}
if len(u.Password) < 8 {
return ErrPasswordTooWeak
}
return nil
}
// BeforeCreate is called before inserting the user into the database
func (u *User) BeforeCreate() {
now := time.Now()
u.CreatedAt = now
u.UpdatedAt = now
}
// BeforeUpdate is called before updating the user in the database
func (u *User) BeforeUpdate() {
u.UpdatedAt = time.Now()
}
// MarshalJSON implements custom JSON marshaling
func (u *User) MarshalJSON() ([]byte, error) {
type Alias User // Prevents recursive MarshalJSON calls
return json.Marshal(&struct {
*Alias
Password string `json:"-"` // Explicitly exclude password
}{
Alias: (*Alias)(u),
})
}
If you have an IDE or your language server complaining right now, fear not. We will be defining the missing error variables.
Define the errors in the models/errors.go
models/errors.go
package models
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrUsernameTooShort = errors.New("username must be at least 3 characters")
ErrPasswordTooWeak = errors.New("password must be at least 8 characters")
ErrUsernameExists = errors.New("username already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
)
// ValidationError represents a domain validation error
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Message
}
// NewValidationError creates a new validation error
func NewValidationError(field, message string) *ValidationError {
return &ValidationError{
Field: field,
Message: message,
}
}
The models package provides several important features:
- Structured Data: The User struct defines the shape of our user data with proper JSON and database tags.
- Data Validation: The ValidateForCreation method ensures data integrity before database operations.
- Lifecycle Hooks: BeforeCreate and BeforeUpdate methods handle timestamp management automatically.
- Security: The Password field is explicitly excluded from JSON serialization using the “-” tag.
- Domain Errors: Custom error types help with precise error handling throughout the application.
Creating the Database
Next create the db
directory. We will put all the database domain logic here.
mkdir -p db
Starting with the db/connection.go
, let’s create our functions to create the database and table.
db/connection.go
package db
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
var DB *sql.DB
func InitDB() error {
var err error
DB, err = sql.Open("sqlite3", "./myapp.db")
if err != nil {
return fmt.Errorf("database connection failed: %v", err)
}
if err := createTables(); err != nil {
return fmt.Errorf("failed to create tables: %v", err)
}
return nil
}
func createTables() error {
query := `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
_, err := DB.Exec(query)
return err
}
func CloseDB() error {
return DB.Close()
}
Define Database Operations
Now define the database operations to be used by the handlers. These will be in db/user.go
.
db/user.go
package db
import (
"database/sql"
"strings"
"myapp/models"
)
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
func (s *UserStore) CreateUser(username, hashedPassword string) error {
user := models.NewUser(username, hashedPassword)
if err := user.ValidateForCreation(); err != nil {
return err
}
user.BeforeCreate()
query := `INSERT INTO users (username, password, created_at, updated_at)
VALUES (?, ?, ?, ?)`
result, err := s.db.Exec(query,
user.Username,
user.Password,
user.CreatedAt,
user.UpdatedAt,
)
if err != nil {
// Check for unique constraint violation
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return models.ErrUsernameExists
}
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
user.ID = id
return nil
}
func (s *UserStore) GetUserByUsername(username string) (*models.User, error) {
var user models.User
query := "SELECT id, username, password FROM users WHERE username = ?"
err := s.db.QueryRow(query, username).Scan(&user.ID, &user.Username, &user.Password)
if err != nil {
return nil, err
}
return &user, nil
}
JWT Token
The jwt
internal package will contain all our jwt auth logic. Create the directory structure.
mkdir -p internal/jwt
The following is just quick and dirty logic for implementing it. Create the internal/jwt/token.go
with the following contents.
internal/jwt/token.go
package jwt
import (
"time"
"github.com/golang-jwt/jwt/v4"
)
var JwtKey = []byte("your-secret-key") // In production, use environment variables
type Claims struct {
Username string `json:"username"`
jwt.StandardClaims
}
func GenerateToken(username string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
Username: username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expirationTime.Unix(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(JwtKey)
}
func ValidateToken(tokenStr string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
return JwtKey, nil
})
if err != nil || !token.Valid {
return nil, err
}
return claims, nil
}
JWT Middleware
Now to add some Gin middleware for our auth. Create the internal/jwt/middleware.go
with this function.
internal/jwt/middleware.go
package jwt
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
claims, err := ValidateToken(tokenParts[1])
if err != nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Set("username", claims.Username)
c.Next()
}
}
Create the handlers for the API
Start by defining a struct to pass a db pointer (and future inputs) to the handlers. Create the handlers
directory.
mkdir -p handlers
And start by creating handlers/auth.go
with the following. auth.go
will be used for the signup and login methods. Technically we could just put all this domain logic in a single go file. But as the application grows, you will start to notice that finding specific logic could start getting scattered and inconsistent.
handlers/auth.go
package handlers
import (
"myapp/db"
)
type AuthHandler struct {
userStore *db.UserStore
}
func NewAuthHandler(userStore *db.UserStore) *AuthHandler {
return &AuthHandler{userStore: userStore}
}
In the handlers/signup.go
we add our signup logic.
handlers/signup.go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"myapp/models"
)
func (h *AuthHandler) Signup(c *gin.Context) {
var user models.User
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword(
[]byte(user.Password),
bcrypt.DefaultCost,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
if err := h.userStore.CreateUser(user.Username, string(hashedPassword)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
c.Status(http.StatusCreated)
}
Now under handlers/login.go
add the login function.
handlers/login.go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"myapp/internal/jwt"
"myapp/models"
)
func (h *AuthHandler) Login(c *gin.Context) {
var user models.User
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
storedUser, err := h.userStore.GetUserByUsername(user.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword(
[]byte(storedUser.Password),
[]byte(user.Password),
); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
token, err := jwt.GenerateToken(user.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
Finally all the user logic under handlers/user.go
. This contains the protected api logic for users.
handlers/user.go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"myapp/db"
"myapp/models"
)
type UserHandler struct {
userStore *db.UserStore
}
func NewUserHandler(userStore *db.UserStore) *UserHandler {
return &UserHandler{userStore: userStore}
}
func (h *UserHandler) GetUser(c *gin.Context) {
// Get username from the JWT claims that were set in the middleware
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "User context not found"})
return
}
// Fetch user details from the database
user, err := h.userStore.GetUserByUsername(username.(string))
if err != nil {
if err == models.ErrUserNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user details"})
return
}
// Return user data (password will be automatically excluded by json:"-" tag)
c.JSON(http.StatusOK, user)
}
Core Application Setup
The main.go
file initializes the server and configures routing. It separates public and authenticated routes.
main.go
package main
import (
"log"
"github.com/gin-gonic/gin"
"myapp/db"
"myapp/handlers"
"myapp/internal/jwt"
)
func main() {
if err := db.InitDB(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.CloseDB()
userStore := db.NewUserStore(db.DB)
authHandler := handlers.NewAuthHandler(userStore)
r := gin.Default()
// Public routes
r.POST("/login", authHandler.Login)
r.POST("/signup", authHandler.Signup)
// Initialize handlers
userHandler := handlers.NewUserHandler(userStore)
// Protected routes
authorized := r.Group("/")
authorized.Use(jwt.AuthMiddleware())
{
authorized.GET("/user", userHandler.GetUser)
}
if err := r.Run(":8080"); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
Running Your API
Simply tidy things up and run with
go mod tidy
go run main.go
Testing Your API
Now that everything’s set up, you can test your API using curl or any API testing tool:
- Create a new user:
curl -X POST http://localhost:8080/signup \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"securepass123"}'
- Log in to get a token:
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"securepass123"}'
- Use the token to access protected routes:
curl http://localhost:8080/user \
-H "Authorization: Bearer <your-token-here>"
Summary
Throughout this tutorial, we’ve followed several security best practices:
- Password hashing using bcrypt
- JWT-based authentication
- Protected routes with middleware
- No sensitive data exposure
- Proper error handling without revealing system details
- Input validation and sanitization
These are just some basics. A lot more can be done (e.g. Add password complexity requirements, rate limiting, better logging, etc). In addition, there’s the whole containerization and deployment to consider.
Again, you can find all this code on Github.
Thanks for reading. Happy coding! 🚀