In this article, you will learn how to implement authentication and role-based authorization in Golang using JSON Web Tokens (JWT). Users will be able to sign up for an account, log in, log out, and access their profile information, which is protected by authentication middleware. This means that users can only access their profile if they have a valid JWT.

Additionally, the route for retrieving users will be restricted to admins and moderators, though you can easily extend this by adding more roles to the list passed to the authorization middleware. Let’s dive right in!

More practice:

JSON Web Token Authentication and Authorization in Golang

Run and Test the JWT Authentication and Authorization Flow in Golang

To get a feel for what we’ll be building in this tutorial, let’s start by downloading the final project, running it, and testing the JSON Web Token (JWT) authentication and authorization flow. Follow the steps below to get started:

  1. Download or clone the Go JWT project from its GitHub repository at https://github.com/wpcodevo/go-postgres-jwt-auth-api. If you downloaded the project as a ZIP file, unzip it and open the folder in your preferred IDE or text editor. If you cloned the repository, open it in your IDE or text editor.
  2. Make sure Docker is running on your computer. You can download it from the official website if it’s not installed. Next, open a terminal in the project’s root directory and run docker-compose up -d to start both the PostgreSQL and pgAdmin servers in their respective Docker containers.
  3. Then, execute the command go run main.go. This will install the required packages, apply the GORM migrations to the PostgreSQL database, and launch the Fiber HTTP server.
  4. Start by importing the Golang JWT Auth.postman_collection.json file from the root directory into Postman or the Thunder Client extension in VS Code. This collection contains predefined HTTP requests, sample data, and configurations to streamline testing the JWT authentication and authorization flow.
  5. With the collection file imported into Postman, you can test the authentication endpoints by sending requests to the Go server. You can register an account, sign in, log out, and access protected routes, such as retrieving profile information.

Bootstrap the Golang Project

Now that you’ve explored the authentication and authorization features of the API, let’s dive into setting up the Go project. Start by navigating to the directory where you want to store the source code, and create a new folder named go-postgres-jwt-auth-api.

Open this folder in your preferred IDE or code editor.

In the IDE’s integrated terminal, initialize the folder as a Go project by running the command below. Be sure to replace <github_username> with your GitHub username:


go mod init github.com//go-postgres-jwt-auth-api

Next, we need to install the dependencies required for adding JWT authentication and role-based authorization features to the Go server. Run the command below to install them:


go get github.com/gofiber/fiber/v2
go get github.com/google/uuid
go get github.com/go-playground/validator/v10
go get -u gorm.io/gorm
go get gorm.io/driver/postgres
go get github.com/spf13/viper
go get github.com/golang-jwt/jwt

  • fiber – A fast and lightweight web framework for building APIs in Go.
  • uuid – A package for generating and handling UUIDs
  • validator – Provides data validation capabilities for struct fields and other Go data types.
  • gorm – An ORM library for Go that simplifies database operations.
  • postgres – A GORM driver that enables PostgreSQL database connections.
  • viper – A configuration management library that simplifies loading environment variables and config files.
  • jwt – A package for generating, parsing, and verifying JSON Web Tokens (JWTs) in Go.

With the dependencies installed, let’s start by setting up a basic Fiber web server with a single endpoint: a health checker. This endpoint will return a JSON object with a simple status message to confirm that the server is running correctly.

In the root directory of your project, create a main.go file and add the following code:

main.go


package main

import (
	"log"

	"github.com/gofiber/fiber/v2"
)

func main() {

	app := fiber.New()

	app.Get("/api/healthchecker", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusOK).JSON(fiber.Map{
			"status":  "success",
			"message": "JSON Web Token Authentication and Authorization in Golang",
		})
	})

	log.Fatal(app.Listen(":8000"))
}

Next, start the Fiber web server by running:

go run main.go

In just a few seconds, the server should be up and running on port 8000. To enable automatic server restarts whenever you make changes, install the Air binary with the following command:

go install github.com/air-verse/air@latest

After installing Air, stop the currently running server and run:

air

This command starts the server with automatic reloading enabled. Now, open your browser and navigate to http://localhost:8000/api/healthchecker. You should see a JSON response confirming that the Go server is running correctly.

testing the health checker route fo the jwt authentication and authorization in Golang

Set up PostgreSQL and pgAdmin with Docker

Next, let’s set up a PostgreSQL database to store the user data for our application. Additionally, we’ll include pgAdmin to provide a graphical user interface for managing the PostgreSQL data.

While there are various ways to set up Postgres and pgAdmin, we’ll use Docker for simplicity. Create a docker-compose.yml file in the root directory and add the following code:

docker-compose.yml


services:
  postgres:
    image: postgres:latest
    container_name: postgres
    ports:
      - '6500:5432'
    volumes:
      - progresDB:/var/lib/postgresql/data
    env_file:
      - ./app.env
  pgAdmin:
    image: dpage/pgadmin4
    container_name: pgAdmin
    env_file:
      - ./app.env
    ports:
      - '5050:80'
volumes:
  progresDB:

You may have noticed that instead of directly including the environment variables for building the postgres and pgAdmin images in the Docker Compose configuration, we referenced an app.env file. This file will contain the necessary environment variables.

To make these variables accessible to Docker Compose, create an app.env file and add the following environment variables:

app.env


POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=6500
POSTGRES_USER=admin
POSTGRES_PASSWORD=password123
POSTGRES_DB=golang_fiber

DATABASE_URL=postgresql://admin:password123@localhost:6500/golang_fiber?schema=public

CLIENT_ORIGIN=http://localhost:3000

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

JWT_SECRET=my_ultra_secure_secret
JWT_EXPIRED_IN=60m
JWT_MAXAGE=60

With the environment variables set, run the command docker-compose up -d to start both the PostgreSQL and pgAdmin servers.

postgres and pgadmin servers running in docker fo the jwt auth in golang

Load Environment Variables into the Application

Now, let’s use the Viper package to load the environment variables from the app.env file, allowing our application to access them. To do this, follow these steps:

  • Create a folder named initializers at the root level of your project.
  • Inside the initializers folder, create a file named env.go and add the following code:

initializers/env.go


package initializers

import (
	"time"

	"github.com/spf13/viper"
)

type Env struct {
	DBHost         string `mapstructure:"POSTGRES_HOST"`
	DBUserName     string `mapstructure:"POSTGRES_USER"`
	DBUserPassword string `mapstructure:"POSTGRES_PASSWORD"`
	DBName         string `mapstructure:"POSTGRES_DB"`
	DBPort         string `mapstructure:"POSTGRES_PORT"`

	JwtSecret    string        `mapstructure:"JWT_SECRET"`
	JwtExpiresIn time.Duration `mapstructure:"JWT_EXPIRED_IN"`
	JwtMaxAge    int           `mapstructure:"JWT_MAXAGE"`

	ClientOrigin string `mapstructure:"CLIENT_ORIGIN"`
}

func LoadEnv(path string) (Env Env, err error) {
	viper.AddConfigPath(path)
	viper.SetConfigType("env")
	viper.SetConfigName("app")

	viper.AutomaticEnv()

	err = viper.ReadInConfig()
	if err != nil {
		return
	}

	err = viper.Unmarshal(&Env)
	return
}

Connect the Application to the PostgreSQL Server

At this stage, we have a PostgreSQL server running in Docker. Now, we need to connect our Go server to this running PostgreSQL instance, and we can achieve this using the GORM package.

GORM will handle the connection pooling, allow us to execute SQL queries directly, and facilitate schema migrations.

To set this up, create a db.go file inside the initializers directory and add the following code:

initializers/db.go


package initializers

import (
	"fmt"
	"log"
	"os"

	"github.com/wpcodevo/go-postgres-jwt-auth-api/models"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var DB *gorm.DB

func ConnectDB(env *Env) {
	var err error
	dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", env.DBHost, env.DBUserName, env.DBUserPassword, env.DBName, env.DBPort)

	DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatal("Failed to connect to the Database")
	}

	DB.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
	DB.Logger = logger.Default.LogMode(logger.Info)

	err = DB.AutoMigrate(&models.User{})
	if err != nil {
		log.Fatal("Migration Failed:  \n", err.Error())
		os.Exit(1)
	}

	fmt.Println("🚀 Connected Successfully to the Database")
}

We used the logger.Default.LogMode() method to enable logging of the SQL queries executed by GORM, allowing us to view the SQL statements directly in the terminal.

Create the User Model and Request Schemas

Now that we have successfully established a connection between our app and the PostgreSQL server, let’s move forward by creating the user model. This will allow us to insert the users table into the database, as well as define other structs for request body validation during the sign-up and sign-in processes.

Inside the root directory of your project, create a new folder called models. Then, within this folder, create a file named user.model.go and include the following code:

models/user.model.go


package models

import (
	"time"

	"github.com/go-playground/validator/v10"
	"github.com/google/uuid"
)

type User struct {
	ID        *uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primary_key"`
	Name      string     `gorm:"type:varchar(100);not null"`
	Email     string     `gorm:"type:varchar(100);uniqueIndex;not null"`
	Password  string     `gorm:"type:varchar(100);not null"`
	Role      *string    `gorm:"type:varchar(50);default:'user';not null"`
	Provider  *string    `gorm:"type:varchar(50);default:'local';not null"`
	Photo     *string    `gorm:"not null;default:'default.png'"`
	Verified  *bool      `gorm:"not null;default:false"`
	CreatedAt *time.Time `gorm:"not null;default:now()"`
	UpdatedAt *time.Time `gorm:"not null;default:now()"`
}

type SignUpInput struct {
	Name            string `json:"name" validate:"required"`
	Email           string `json:"email" validate:"required"`
	Password        string `json:"password" validate:"required,min=8"`
	PasswordConfirm string `json:"passwordConfirm" validate:"required,min=8"`
	Photo           string `json:"photo"`
}

type SignInInput struct {
	Email    string `json:"email"  validate:"required"`
	Password string `json:"password"  validate:"required"`
}

type UserResponse struct {
	ID        uuid.UUID `json:"id,omitempty"`
	Name      string    `json:"name,omitempty"`
	Email     string    `json:"email,omitempty"`
	Role      string    `json:"role,omitempty"`
	Photo     string    `json:"photo,omitempty"`
	Provider  string    `json:"provider"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

func FilterUserRecord(user *User) UserResponse {
	return UserResponse{
		ID:        *user.ID,
		Name:      user.Name,
		Email:     user.Email,
		Role:      *user.Role,
		Photo:     *user.Photo,
		Provider:  *user.Provider,
		CreatedAt: *user.CreatedAt,
		UpdatedAt: *user.UpdatedAt,
	}
}

var validate = validator.New()

type ErrorResponse struct {
	Field string `json:"field"`
	Tag   string `json:"tag"`
	Value string `json:"value,omitempty"`
}

func ValidateStruct[T any](payload T) []*ErrorResponse {
	var errors []*ErrorResponse
	err := validate.Struct(payload)
	if err != nil {
		for _, err := range err.(validator.ValidationErrors) {
			var element ErrorResponse
			element.Field = err.StructNamespace()
			element.Tag = err.Tag()
			element.Value = err.Param()
			errors = append(errors, &element)
		}
	}
	return errors
}

  1. User: This struct defines the attributes of a user, which will later be used by GORM to create the corresponding SQL table in the database.
  2. SignUpInput: This struct outlines the format of the data included in the request body during the user sign-up process.
  3. SignInInput: This struct specifies the format of the data included in the request body during the user sign-in process.
  4. FilterUserRecord: This function returns a UserResponse struct containing only the relevant user attributes that should be exposed in the JSON response, excluding sensitive information like passwords.
  5. ValidateStruct: This function validates the incoming data based on the struct passed as an argument, ensuring it meets the defined validation rules.

Implement Authentication Logic

With the code in place to create the users table in the database, we can now move on to implementing the authentication route handlers.

To keep things straightforward, we will create three handlers: one for signing up new users, one for logging in existing users, and another for logging out users.

Sign Up User Route Handler

Let’s begin with the sign-up route handler. We will name it SignUpUser. When invoked, it will first extract the user’s credentials from the request body and validate them against our predefined rules.

Next, it will check if the password matches the confirmation password. If they match, the function will hash the plain-text password and store the user’s credentials in the database using GORM.

Since the email field is indexed as unique, if a user with the same email already exists, a 409 conflict error will be returned, indicating that the email is already in use.

Otherwise, the user record returned from the database will exclude sensitive fields, such as the password, and only the remaining data will be included in the JSON response.

To implement the sign-up route handler, create a new folder named handlers at the root level of your project. Inside this folder, create a file called auth.handler.go and add the following code:

handlers/auth.handler.go


func SignUpUser(c *fiber.Ctx) error {
	var payload *models.SignUpInput

	if err := c.BodyParser(&payload); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	errors := models.ValidateStruct(payload)
	if errors != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "errors": errors})

	}

	if payload.Password != payload.PasswordConfirm {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Passwords do not match"})

	}

	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)

	if err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	newUser := models.User{
		Name:     payload.Name,
		Email:    strings.ToLower(payload.Email),
		Password: string(hashedPassword),
		Photo:    &payload.Photo,
	}

	result := initializers.DB.Create(&newUser)

	if result.Error != nil && strings.Contains(result.Error.Error(), "duplicate key value violates unique") {
		return c.Status(fiber.StatusConflict).JSON(fiber.Map{"status": "fail", "message": "User with that email already exists"})
	} else if result.Error != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "error", "message": "Something bad happened"})
	}

	return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "success", "data": fiber.Map{"user": models.FilterUserRecord(&newUser)}})
}

Sign In User Route Handler

Next, let’s implement the sign-in route handler. This handler will first extract the credentials from the request body and validate them against our predefined rules. It will then look up the user in the database based on the email provided in the request.

If the user is found, the handler will compare the provided password with the hashed password stored in the database. If the passwords match, the handler will generate a new JSON Web Token (JWT), which will be returned in the JSON response as well as set as a cookie.

Below is the implementation of the route handler:

handlers/auth.handler.go


func SignInUser(c *fiber.Ctx) error {
	var payload *models.SignInInput

	if err := c.BodyParser(&payload); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	errors := models.ValidateStruct(payload)
	if errors != nil {
		return c.Status(fiber.StatusBadRequest).JSON(errors)

	}

	var user models.User
	result := initializers.DB.First(&user, "email = ?", strings.ToLower(payload.Email))
	if result.Error != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Invalid email or Password"})
	}

	err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(payload.Password))
	if err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Invalid email or Password"})
	}

	config, _ := initializers.LoadEnv(".")

	tokenByte := jwt.New(jwt.SigningMethodHS256)

	now := time.Now().UTC()
	claims := tokenByte.Claims.(jwt.MapClaims)

	claims["sub"] = user.ID
	claims["exp"] = now.Add(config.JwtExpiresIn).Unix()
	claims["iat"] = now.Unix()
	claims["nbf"] = now.Unix()

	tokenString, err := tokenByte.SignedString([]byte(config.JwtSecret))

	if err != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": fmt.Sprintf("generating JWT Token failed: %v", err)})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "token",
		Value:    tokenString,
		Path:     "/",
		MaxAge:   config.JwtMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "token": tokenString})
}

Logout User Route Handler

Finally, let’s implement the logout route handler. This handler is straightforward; it will send an expired cookie to the user’s browser or API client, effectively deleting the existing cookie that was set during the sign-in process.

Below is the implementation of the route handler:

handlers/auth.handler.go


func LogoutUser(c *fiber.Ctx) error {
	expired := time.Now().Add(-time.Hour * 24)
	c.Cookie(&fiber.Cookie{
		Name:    "token",
		Value:   "",
		Expires: expired,
	})
	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success"})
}

The Complete Auth Route Handlers

Below is the full implementation of the sign-up, sign-in, and logout route handlers:

handlers/auth.handler.go


package handlers

import (
	"fmt"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/golang-jwt/jwt"
	"github.com/wpcodevo/go-postgres-jwt-auth-api/initializers"
	"github.com/wpcodevo/go-postgres-jwt-auth-api/models"
	"golang.org/x/crypto/bcrypt"
)

func SignUpUser(c *fiber.Ctx) error {
	var payload *models.SignUpInput

	if err := c.BodyParser(&payload); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	errors := models.ValidateStruct(payload)
	if errors != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "errors": errors})

	}

	if payload.Password != payload.PasswordConfirm {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Passwords do not match"})

	}

	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)

	if err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	newUser := models.User{
		Name:     payload.Name,
		Email:    strings.ToLower(payload.Email),
		Password: string(hashedPassword),
		Photo:    &payload.Photo,
	}

	result := initializers.DB.Create(&newUser)

	if result.Error != nil && strings.Contains(result.Error.Error(), "duplicate key value violates unique") {
		return c.Status(fiber.StatusConflict).JSON(fiber.Map{"status": "fail", "message": "User with that email already exists"})
	} else if result.Error != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "error", "message": "Something bad happened"})
	}

	return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "success", "data": fiber.Map{"user": models.FilterUserRecord(&newUser)}})
}

func SignInUser(c *fiber.Ctx) error {
	var payload *models.SignInInput

	if err := c.BodyParser(&payload); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	errors := models.ValidateStruct(payload)
	if errors != nil {
		return c.Status(fiber.StatusBadRequest).JSON(errors)

	}

	var user models.User
	result := initializers.DB.First(&user, "email = ?", strings.ToLower(payload.Email))
	if result.Error != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Invalid email or Password"})
	}

	err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(payload.Password))
	if err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "fail", "message": "Invalid email or Password"})
	}

	config, _ := initializers.LoadEnv(".")

	tokenByte := jwt.New(jwt.SigningMethodHS256)

	now := time.Now().UTC()
	claims := tokenByte.Claims.(jwt.MapClaims)

	claims["sub"] = user.ID
	claims["exp"] = now.Add(config.JwtExpiresIn).Unix()
	claims["iat"] = now.Unix()
	claims["nbf"] = now.Unix()

	tokenString, err := tokenByte.SignedString([]byte(config.JwtSecret))

	if err != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": fmt.Sprintf("generating JWT Token failed: %v", err)})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "token",
		Value:    tokenString,
		Path:     "/",
		MaxAge:   config.JwtMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "token": tokenString})
}

func LogoutUser(c *fiber.Ctx) error {
	expired := time.Now().Add(-time.Hour * 24)
	c.Cookie(&fiber.Cookie{
		Name:    "token",
		Value:   "",
		Expires: expired,
	})
	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success"})
}

Create the Authentication Middleware

Now, let’s create middleware to verify that a valid JSON Web Token (JWT) is provided before allowing the request to continue through the middleware pipeline.

This middleware, called DeserializeUser, will first check for a JWT in the Authorization header. If none is found, it will then look in the cookies. If a token isn’t present in either location, a 401 Unauthorized error is returned, indicating that the user is not logged in.

If a token is found, DeserializeUser will validate it. Once validated, it extracts the payload from the JWT, retrieves the user ID, and uses GORM to find the corresponding user record in the database.

If a user is found, sensitive information is stripped from the user record, and the remaining user data is stored in the Fiber context’s Locals object, making it accessible to subsequent middleware in the pipeline.

Here’s the JWT middleware implementation:

middleware.go


func DeserializeUser(c *fiber.Ctx) error {
	var tokenString string
	authorization := c.Get("Authorization")

	if strings.HasPrefix(authorization, "Bearer ") {
		tokenString = strings.TrimPrefix(authorization, "Bearer ")
	} else if c.Cookies("token") != "" {
		tokenString = c.Cookies("token")
	}

	if tokenString == "" {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "fail", "message": "You are not logged in"})
	}

	config, _ := initializers.LoadEnv(".")

	tokenByte, err := jwt.Parse(tokenString, func(jwtToken *jwt.Token) (interface{}, error) {
		if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %s", jwtToken.Header["alg"])
		}

		return []byte(config.JwtSecret), nil
	})
	if err != nil {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "fail", "message": fmt.Sprintf("invalidate token: %v", err)})
	}

	claims, ok := tokenByte.Claims.(jwt.MapClaims)
	if !ok || !tokenByte.Valid {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "fail", "message": "invalid token claim"})

	}

	var user models.User
	initializers.DB.First(&user, "id = ?", fmt.Sprint(claims["sub"]))

	if user.ID.String() != claims["sub"] {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": "the user belonging to this token no logger exists"})
	}

	c.Locals("user", models.FilterUserRecord(&user))

	return c.Next()
}

Implement Role-Based Authorization Middleware

With our middleware for protecting private routes in place, we can now implement an authorization middleware to control access based on user roles. This middleware, called allowedRoles, is designed to be flexible, allowing multiple roles to access specific routes. It returns a fiber.Handler that performs the actual authorization check.

When allowedRoles is invoked, it checks the Locals object in Fiber’s context for the user object, which the DeserializeUser middleware stored. For this reason, allowedRoles must always follow DeserializeUser in the middleware stack.

If a user exists in Locals, the middleware verifies whether the user’s role matches any of the specified allowed roles. If there’s a match, the request proceeds; otherwise, a 403 Forbidden error is returned with the message “Access denied.

Below is the implementation of the authorization middleware:

middleware.go


func allowedRoles(allowedRoles []string) fiber.Handler {
	return func(c *fiber.Ctx) error {
		user, ok := c.Locals("user").(models.UserResponse)

		if !ok {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
				"status":  "fail",
				"message": "Access denied. User not authenticated.",
			})
		}

		roleAllowed := false
		for _, allowedRole := range allowedRoles {
			if user.Role == allowedRole {
				roleAllowed = true
				break
			}
		}

		if !roleAllowed {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
				"status":  "fail",
				"message": "Access denied. You are not allowed to perform this action.",
			})
		}

		return c.Next()
	}
}

Create User-Related Route Handlers

With the authentication functionality complete, let’s move on to creating the remaining API route handlers. The first handler will return a user’s public profile information, and it will be accessible to all roles within the application. The second handler will return a paginated list of users, restricted to admins and moderators only.

Get Profile Information Route Handler

This handler is simple: it retrieves the user object stored in the Fiber context’s Locals by the DeserializeUser middleware and returns it in a JSON response.

handlers/user.handler.go


func GetMeHandler(c *fiber.Ctx) error {
	user := c.Locals("user").(models.UserResponse)

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "data": fiber.Map{"user": user}})
}

Get Users Route Handler

Here, we’ll define a handler called GetUsersHandler that supports pagination. By default, it returns the first 10 users if no limit or page parameters are provided in the request URL. This handler is restricted to admins and moderators only.

Below is the implementation of this route handler:

handlers/user.handler.go


func GetUsersHandler(c *fiber.Ctx) error {
	var page = c.Query("page", "1")
	var limit = c.Query("limit", "10")

	intPage, _ := strconv.Atoi(page)
	intLimit, _ := strconv.Atoi(limit)
	offset := (intPage - 1) * intLimit

	var users []models.User
	results := initializers.DB.Limit(intLimit).Offset(offset).Find(&users)
	if results.Error != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "error", "message": results.Error})
	}

	var userResponses []models.UserResponse
	for _, user := range users {
		userResponses = append(userResponses, models.UserResponse{
			ID:        *user.ID,
			Name:      user.Name,
			Email:     user.Email,
			Role:      *user.Role,
			Photo:     *user.Photo,
			Provider:  *user.Provider,
			CreatedAt: *user.CreatedAt,
			UpdatedAt: *user.UpdatedAt,
		})
	}

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "results": len(userResponses), "users": userResponses})
}

Set up Routes for the Handlers

With all the route handlers in place, let’s set up the routes that will invoke them. In the root directory, create a routes.go file and add the following code:

route.go


func SetupRoutes(app fiber.Router) {
	authRoutes := app.Group("/auth")
	authRoutes.Post("/register", handlers.SignUpUser)
	authRoutes.Post("/login", handlers.SignInUser)
	authRoutes.Get("/logout", DeserializeUser, handlers.LogoutUser)

	app.Get("/users/me", DeserializeUser, handlers.GetMeHandler)
	app.Get("/users/", DeserializeUser, allowedRoles([]string{"admin", "moderator"}), handlers.GetUsersHandler)
}

In this configuration:

  • /auth/register: Registers a new user.
  • /auth/login: Logs in an existing user.
  • /auth/logout: Logs out the user by removing the authentication token.

For user-related endpoints:

  • /users/me: Retrieves the profile of the logged-in user.
  • /users/: Returns a paginated list of users, accessible only to admins and moderators.

Register the Router and Configure CORS

To wrap up, let’s register the Fiber router we created earlier. This will ensure the server recognizes all available routes. Additionally, we’ll configure CORS to allow the server to accept requests from cross-origin domains, making it accessible to client applications hosted elsewhere.

To achieve this, open the main.go file and replace its existing content with the following code:

main.go


package main

import (
	"fmt"
	"log"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/logger"
	"github.com/wpcodevo/go-postgres-jwt-auth-api/initializers"
)

func main() {
	env, err := initializers.LoadEnv(".")
	if err != nil {
		log.Fatal("🚀 Could not load environment variables", err)
	}
	initializers.ConnectDB(&env)

	app := fiber.New()
	micro := fiber.New()

	app.Mount("/api", micro)
	app.Use(logger.New())
	app.Use(cors.New(cors.Config{
		AllowOrigins:     "http://localhost:3000",
		AllowHeaders:     "Origin, Content-Type, Accept",
		AllowMethods:     "GET, POST",
		AllowCredentials: true,
	}))

	SetupRoutes(micro)

	micro.Get("/healthchecker", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusOK).JSON(fiber.Map{
			"status":  "success",
			"message": "JSON Web Token Authentication and Authorization in Golang",
		})
	})

	app.All("*", func(c *fiber.Ctx) error {
		path := c.Path()
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"status":  "fail",
			"message": fmt.Sprintf("Path: %v does not exists on this server", path),
		})
	})

	log.Fatal(app.Listen(":8000"))
}

Testing the JWT Authentication and Authorization Features of the API

Now that the project is complete, let’s move on to testing the API’s authentication and role-based authorization features. I’ll be using Postman as my API client, but you’re welcome to use any client you prefer.

If you’re using Postman, you can access the same collection I used for testing. To do so, first, clone the project associated with this article from https://github.com/wpcodevo/go-postgres-jwt-auth-api.

In the root directory, you’ll find a JSON file named Golang JWT Auth.postman_collection.json. Import this file into Postman to access predefined request methods, URLs, and data for testing the API.

Sign Up

To sign up, add your credentials—such as name, email, password, password confirmation, and photo—to the request body. Then send a POST request to the /api/auth/register endpoint.


{
    "email": "johndoe@gmail.com",
    "name": "John Doe",
    "password": "password123",
    "passwordConfirm": "password123",
    "photo": "default.png"
}

In a few seconds, you should receive a response with a 201 status code, indicating that your account has been successfully created.

register new account golang json web token auth and authorization

Sign In

Now that your account is created, you can use the same credentials to sign in to the API. To do this, add your email and password to the request body and send a POST request to the /api/auth/login endpoint.


{
    "email": "johndoe@gmail.com",
    "password": "password123"
}

Once the credentials are verified, you will receive a JWT in the JSON response, with a copy also stored in a cookie. This token, set in the cookie, will allow you to access the private routes of the API.

login user json web token in golang and gorm

Get Your Profile Information

To retrieve your account details, authentication is typically required. However, since you’re logged in, you can simply send a GET request to the /api/users/me endpoint. You should receive your profile information in the JSON response.

retrieve your profile information json web token auth and authorization

Admin Retrieve Users

Now, let’s test the role-based authorization feature of the API. To begin, we need to create an Admin account. Add the following JSON data to the request body of your API client and send a POST request to the /api/auth/register endpoint.


{
    "email": "admin@admin.com",
    "name": "Admin",
    "password": "password123",
    "passwordConfirm": "password123",
    "photo": "default.png"
}

Once the Admin account is created, sign in to the API using the same email and password. After logging in, try retrieving the list of users from the database by sending a GET request to the /api/users endpoint. You should receive a 403 error, indicating that access is denied.

attempting to retrieve all users but the route is only accessible to admins and moderators

To resolve this, we need to update the role of the Admin to ‘admin’ in the database. We can do this using pgAdmin. First, access pgAdmin by visiting http://localhost:5050 and sign in with the credentials provided in the app.env file.

Next, register the PostgreSQL server. Click on ‘Add Server’ and enter the necessary values as shown in the screenshot below. These credentials can also be found in the app.env file, with the password set as password123. After filling out the required fields, click the ‘Save’ button to register the server.

register the postgres server on pgAdmin json web token in golang

Open the query tool for the users table and execute the statement SELECT * FROM users; to retrieve the list of users in the database. Then, copy the ID of the admin record.


select * from users;

Clear the previous SQL statement and execute the following update statement to change the role from ‘user’ to ‘admin’. Be sure to replace <id_of_admin> with the ID of the admin record you copied earlier.


update users set role = 'admin' where id = '' returning *;

Now that the Admin’s role has been updated to ‘admin‘, send a GET request to the /api/users endpoint once again. This time, you should receive the first 10 users, assuming no page or limit query parameters were provided.

admin retrieving all users json web token golang

Conclusion

And we are done! Congratulations if you’ve made it this far. In this comprehensive guide, you’ve learned how to build an API with authentication and role-based authorization using JSON Web Tokens.

I hope you found the article both helpful and enjoyable. If you have any questions or feedback, don’t hesitate to leave them in the comment section below. Thanks for reading!