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:
- CRUD Operations on PostgreSQL with a Golang REST API
- Perform CRUD Operations with the net/http Go Standard Library
- Golang CRUD API Example with GORM and MySQL
- Golang CRUD RESTful API with SQLC and PostgreSQL
- Create CRUD API in Golang using Fiber and GORM
- Build a Simple API with Rust and Rocket
- Build a CRUD API with Rust and MongoDB
- Build a Simple API with Rust and Actix Web
- Build a CRUD API with Node.js and Sequelize
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:
- 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.
- 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. - 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. - 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. - 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 UUIDsvalidator
– 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.
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.
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 namedenv.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
}
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.SignUpInput
: This struct outlines the format of the data included in the request body during the user sign-up process.SignInInput
: This struct specifies the format of the data included in the request body during the user sign-in process.FilterUserRecord
: This function returns aUserResponse
struct containing only the relevant user attributes that should be exposed in the JSON response, excluding sensitive information like passwords.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.
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.
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.
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.
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.
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.
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!