Step-by-Step Guide to Secure Authentication in Go using JWT and Fiber

Step-by-Step Guide to Secure Authentication in Go using JWT and Fiber

How to Create Secure User Authentication in Go with JWT and Fiber

Introduction

  • After the last series on Go, I've been learning some new ideas, like Secure Authentication. I discovered JWT authentication and how it helps secure routes.

  • In this article, I'll explain how we can use JWT to authenticate users and secure routes.

  • So, let's jump right in.


Creating a Base Folder Structure

  • We'll start by setting up a project structure to clearly see how everything fits together.

  • First, create a directory and open your favorite code editor. I prefer VS Code.

bmkdir go-auth
cd go-auth
code .

  • So this is our project structure :

cmd/server/main.go

  • This is the entry point of the application

controllers/auth_controller.go

  • This is where we place our controllers/handlers. In our case, it will be auth_controller. Here we will implement all the functions related to authentication like Register, Login, and Logout.

database/db.go

  • In this database, setup is done and the connection is made. We will be using MySQL as our database.

middleware/middleware.go

  • Middleware is simply a function that wraps around our HTTP handlers to add extra features. For example, once a user is logged in, we use middleware to secure other routes like /dashboard and /cart. We will learn about middleware in this blog.

models/user.go

  • The model represents the data structure of the application’s data. So for example, if you are building an application related to a library, models can be, a book, author, etc. In this project which we are building, we need user a model as we are dealing with the user’s authentication.

routes/routes.go

  • We define all the HTTP routes here. We are using fiber a package for managing the application routes. If you are familiar with express in Node.js then this package is inspired by it and provides flexible ways and functionalities to manage routes.

types/types.go

  • In this, we define our own custom struct types which we require in our application.

.env

  • This is an environmental file where we put all the secure stuff.

Load .env File

  • First of all, let’s load all the environmental variables in our application.

  • For that, we are using a package called gotdotenv. This helps us achieve this.

go get github.com/joho/godotenv
  • Let’s load this env file inside our application. Go to main.go and paste below code
import "github.com/joho/godotenv"

func main() {
    err := godotenv.Load()
    if err != nil {
        log.Fatal("Error loading .env file")
    }
}
  • We place it at the top of the main function because we need all the variables loaded before the server starts.

Connect Database

  • We are using MySQL to work with the database. I was going through some queries and everything as It’s been a while since I wrote any. And then I came across the term ORM (Object-Relation Mapping). And I found it super convenient as we can use objects oriented approach to execute the queries.

  • So an ORM is a programming technique that allows us to interact with a relational database like MySQL using object-oriented programming principles, instead of writing raw SQL queries. It serves as a bridge between the object-oriented language and the database, mapping database tables to objects in the application.

  • And researching more about it I found this package called gorm for Go. So we will be using this to work with our database.

  • Let’s install it and also we need to install the mysql driver for it separately.

go get -u gorm.io/gorm
go get gorm.io/driver/mysql
  • Let’s now create Connect function and write the logic to connect our application with the database.
package database

import (
    "fmt"
    "os"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func Connect() error {
    dsn := fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/go_auth?charset=utf8mb4&parseTime=True&loc=Local", os.Getenv("DB_USERNAME"), os.Getenv("DB_PASSWORD"))
}
  • Inside Connect() we first initilized dsn (Data Source Name). dsn is nothing but a string that provides all the information to connect the database. It includes information like database type, server location, username password, database name, etc.

  • Here we used fmt.Sprintf which returns a formatted string. We used two string %s placeholders for username and password. And we are getting those values from the .env file.

  • Let’s first add those values to our .env

// .env
DB_USERNAME=root
DB_PASSWORD=root123
func Connect() error {
    dsn := fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/go_auth?charset=utf8mb4&parseTime=True&loc=Local", os.Getenv("DB_USERNAME"), os.Getenv("DB_PASSWORD"))
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return err
    }
}
  • After that, we open the connection to the MySQL database using Gorm's Open() function. We use the mysql driver's Open function to connect with our dsn string. The second parameter is for gorm configuration if you have any.

  • This gorm.Open function returns two things: db instance and error. So what we need to do is, first we need to create a global gorm.DB variable that our application can use from anywhere inside the project.

var DB *gorm.DB

func Connect() error {
    // ...
}
  • And that’s why we create var DB *gorm.DB .It’s a pointer because we need whatever changes we made in this variable, it reflects the original one which is db inside the Connect function.
func Connect() error {
    dsn := fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/go_auth?charset=utf8mb4&parseTime=True&loc=Local", os.Getenv("DB_USERNAME"), os.Getenv("DB_PASSWORD"))
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return err
    }

    log.Info("Database connected successfully")
    DB = db

    db.AutoMigrate(&models.User{})
    return nil
}
  • And that is why we assigned db to global DB variable.

  • Then we have this db.AutoMigrate(&User{}) function. What this AutoMirgrate function does is, it creates the same table with all the values defined inside this model User inside the database.

  • This means that GORM will ensure that the table corresponding to the User model in the database matches the structure defined in the model, creating or updating columns as necessary.

  • And so when you run the application for the first time, it will create a table for you automatically.

  • We haven’t created our User model yet so let’s create it first.

User Model

package models

type User struct {
    Id       
    Name    
    Email    
    Password
}
  • Here we’ve created a simple User struct, which contains basic information like ID, Name Email, and Password.

  • Now that we have the database file ready we can use the Connect function inside our main function to connect the database.
func main() {
    err := godotenv.Load()
    if err != nil {
        log.Fatal("Error loading .env file")
    }

    if err = database.Connect(); err != nil {
        log.Fatalf("Could not connect to the database, %v", err)
    }
}
  • Now let’s run the project to see if it’s working
go run cmd/server/main.go


Setting Up the Router

  • Okay so for route management, as we discussed earlier we are using fiber package. To install run the following command inside your terminal
go get github.com/gofiber/fiber/v2
  • Let’s create an instance of fiber inside main.go to use it inside our application
func main() {
    //...

    app := fiber.New()
}
  • Using this app we can now create our routes.

  • To make things look clean and separate we will implement all the routes inside routes.go.

// routes.go
func SetupRoutes(app *fiber.App) {
    app.Post("/api/register", controllers.Register)
    app.Post("/api/login", controllers.Login)
    app.Post("/api/logout", controllers.Logout)
}
  • Here we have created SetupRoutes a function that takes a app variable of type *fiber.App.

  • Inside this, we created three routes, /register, login/ and /logout. (Don’t worry if you are seeing an error saying these functions are not found. We are going to implement them in a bit)

  • Now we call this function in main.go like this

func main() {
    //...

    routes.SetupRoutes(app)
}
  • Now let’s implement these handler functions inside controllers.

Implementing Route Handlers

Register

// controllers/auth_controller.go
func Register(c *fiber.Ctx) error {
    var regReq types.RegisterRequest
}
  • Since we receive the submitted data in an HTTP request, we need to create a custom struct type to convert all that data into a format that Go can understand.

  • Let’s create a RegisterRequest struct for the Register handler

// types/types.go

type RegisterRequest struct {
    Name    
    Email   
    Password
}
  • Let’s now parse the request body of the register to this type
func Register(c *fiber.Ctx) error {
    var regReq types.RegisterRequest

    if err := c.BodyParser(&regReq); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "message": "invalid request format",
        })
    }
}
  • Function BodyParser helps bind the body fields to a struct field. It returns an error of type ErrUnprocessableEntity which means if BodyParser fails to bind the fields it will throw this error.

  • So we handled it by returning an error message “invalid request format“ with a status code 500 (StatusInternalServerError)

  • Now before we create our user and send it to the database to store, we can’t just send the password as it is. We need to secure it by converting it into some hash or something.

  • Go has this package called bcrypt which helps us achieve that. First, let’s install the package

go get golang.org/x/crypto/bcrypt
  • bcrypt has a function GenerateFromPassword that takes a byte of password string as the first parameter and cost as the second parameter (cost determines the computational complexity of the hashing process. It is a measure of how long it takes to hash a password, directly affecting the time it takes to verify the hash later)

  • Let’s hash our password and then create a user object

func Register(c *fiber.Ctx) error {
    //...

    password, _ := bcrypt.GenerateFromPassword([]byte(regReq.Password), 10)

    user := models.User{
        Name:     regReq.Name,
        Email:    regReq.Email,
        Password: password,
    }
}
  • This bcrypt function returns two values: encrypted password and error.

  • And after that, we can construct our user model by using regReq struct and encrypted password.

  • Now before we send this to the database, we need to first check whether this user already exists in the database or not.


func Register(c *fiber.Ctx) error {
    // ...   

    database.DB.Where("email = ?", user.Email).First(&user)
}
  • We can do this using the Where function of the MySQL database. Since each email is unique in our application, we wrote a query that returns a user if they exist.

  • Now we check if we found anything or not like this:


func Register(c *fiber.Ctx) error {
    // ...   

    database.DB.Where("email = ?", user.Email).First(&user)

    if user.Id != 0 {
        return c.Status(fiber.StatusConflict).JSON(fiber.Map{
            "message": "user already exists",
        })
    }
}
  • If user.Id is not 0, it means we found a user, so we return an error message saying "user already exists."

  • If we didn't find anything, we create a new user in the database using the Create() function of DB.

func Register(c *fiber.Ctx) error {
    // ...

    database.DB.Create(&user)
}
  • And once everything is done, we send a message back to the API response saying "user created successfully" along with the user information.
func Register(c *fiber.Ctx) error {
    // ...

    return c.JSON(fiber.Map{
            "message": "user created successfully",
            "data":    user,
        })
}
  • That was pretty straightforward, wasn’t it?

Running the Server

  • To run the server, we use the app's Listen function and specify the port number like this:
// main.go

func main() {
    //...

    log.Println("Starting server at: ", 3000)
    err = app.Listen(":3000")
    if err != nil {
        log.Fatalln("Error Starting Server at: ", 3000)
    }
}
  • Now let’s run the app


Login

// controllers/auth_controller.go

func Login(c *fiber.Ctx) error {
    var loginReq types.LoginRequest

    if err := c.BodyParser(&loginReq); err != nil {
        return err
    }

    if err := validate.Struct(&loginReq); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "message": "Validation failed",
            "errors":  err.Error(),
        })
    }
}
  • This part of the Login remains the same as the Register. We have created a LoginRequest structure inside types.go
// types/types.go

package types

...

type LoginRequest struct {
    Email 
    Password
}
  • Now, we need to check if the user exists in the database when the client tries to log in with their credentials.

  • To do this, we will use the Where() function of DB to see if the email is in the database. If it exists (we check this by seeing if user.Id is not 0), we proceed with further operations otherwise, we return an error.

func Login(c *fiber.Ctx) error {    
     // ...

    var user models.User

    database.DB.Where("email = ?", loginReq.Email).First(&user)

    if user.Id == 0 {
        c.Status(fiber.StatusNotFound)
        return c.JSON(fiber.Map{
            "message": "user not found",
        })
    }
}
  • If the user exists, we compare the incoming password with the one in the database. If they match, the credentials are correct. If they don't match, we return an error saying "incorrect password."
func Login(c *fiber.Ctx) error {    
     // ...
     if err := bcrypt.CompareHashAndPassword(user.Password, []byte(loginReq.Password)); err != nil {
        c.Status(fiber.StatusBadRequest)
        return c.JSON(fiber.Map{
            "message": "incorrect password",
        })
    }
}
  • bcrypt has a function called CompareHashAndPassword that checks the incoming password against the one stored in the database.

  • After that, we just send a JSON response with a success message and the data.

func Login(c *fiber.Ctx) error {    
     // ...
    return c.JSON(fiber.Map{
        "message": "success",
        "data":    loginReq,
    })
}
  • Let’s check the Login handler by running the project.


Logout

  • The logout feature is very simple. On the backend, there's not much to do. The frontend should handle the logout process.

  • So, when this route is accessed, we just return a success message.

func Logout(c *fiber.Ctx) error {

    return c.JSON(fiber.Map{
        "message": "Logged out successfully",
    })
}

  • One problem right now here is, that even if you aren’t logged in, you can still log out. And this issue we will resolve below by using JWT.

JSON Tags

  • If you see the response of Login and Register…

  • We notice that the field names are not in lowercase. To convert these fields into a JSON-friendly format, we use what are called Tags in Go.

  • Tags specify how struct fields should be encoded into or decoded from JSON. So, even if we have a struct field like Name, we can encode and decode it as name using an annotation.

  • To achieve this, we need to make changes inside types.go and user.go.

// user.go

package models

type User struct {
    Id       uint   `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email" gorm:"unique"`
    Password []byte `json:"-"`
}
  • Here, we made the email a unique key in the database. This means the same email address can't be used again.

  • Another thing to note is that we added - in front of Password because we don't want this field to appear in the response for security reasons.

  • Now, let's modify types.go.

package types

type RegisterRequest struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}
  • Now if you run the project and see the response, it should be fixed


JWT Authentication

  • Let’s first understand what JWT is before we dive into code.

What is JWT?

  • JWT (JSON Web Token) is a way to safely send data between the client and server.

  • It is a token made up of three main parts:

    • Header: This includes information like the signing method and type of token.

    • Payload: This has Claims, which are details about the user data, such as user ID and expiration time.

    • Signature: This is made by signing the header and payload with a secret or private key. It checks if the token is real.

Flow of JWT Authentication

  • The client sends credentials to the server.

  • If the credentials are valid, the server generates a JWT containing the user's information and signs it.

  • The server sends the JWT to the client, typically as part of the HTTP response.

  • The client stores the token (e.g., in local storage or an HTTP-only cookie).

  • The client includes the JWT in the Authorization header (ex, Bearer <token>).

  • The server validates the token’s signature and checks its claims (e.g., expiration time, roles).

  • If valid, the server processes the request; otherwise, it rejects it.

Implement JWT Authentication

  • To use JWT in our app, let’s first add the package
go get github.com/dgrijalva/jwt-go/v4
  • In our app, when a user logs in successfully, we create a JWT token. After checking the password, we start by creating a claim to build the token.
if err := bcrypt.CompareHashAndPassword(...); err != nil {//...}

claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
        Issuer: strconv.Itoa(int(user.Id)),
        ExpiresAt: &jwt.Time{
            Time: time.Now().Add(time.Hour * 24),
        },
    })
  • jwt provides a function called NewWithClaims to create a Claim. It takes two parameters:

    • Signing Method: We can choose from several secure hashing algorithms, but we are using HS256.

    • Claim: We include the claims we mentioned earlier, like user ID and expiry time, using the jwt.StandardClaims interface. We set the user ID in Issuer as a string and ExpiresAt with a time value, set to 24 hours. This means the token will expire after 24 hours.

    • Now that we have the claims ready, we will create a token.

token, err := claims.SignedString([]byte(os.Getenv("JWT_SECRET")))
    if err != nil {
        c.Status(fiber.StatusInternalServerError)
        return c.JSON(fiber.Map{
            "message": "could not log in",
        })
    }
  • As you can see claims have a method named SignedString which we use to sign the JWT secret. We typically store the secret inside .env file.
// .env

// ... other keys
JWT_SECRET=secret
  • Use a strong secret key for better security. I have used secret for testing purposes.

  • And after everything’s done we get the token.

  • If you remember this line:

“ A JWT token is simply a mix of signed claims and secret/private keys. I hope this explanation makes it clear.”

  • And as mentioned in the Flow of JWT part we send this token to the client so that they can send it back to the backend to access secure/private routes
// auth_controller.go

func Login(c *fiber.Ctx) error {

// ...

return c.JSON(fiber.Map{
        "message": "success",
        "token":   token,
        "data":    loginReq,
    })

}
  • Okay so, now that we created the JWT token. Whenever a user tries to access private routes, we need to first validate the user, whether he is logged in or not. To do that we create a middleware

Middlewares

  • In Go, we create middleware that acts as a layer between the HTTP request and the handler that processes it.

  • Middleware is simply a function that takes an HTTP handler and returns an HTTP handler. In our app, we need middleware to ensure the user accessing the route is authorized to access private routes before entering.

  • To implement it, go to middleware/middlewares.go and paste the code below.

package middleware

import (
    "os"

    jwtware "github.com/gofiber/contrib/jwt"
    "github.com/gofiber/fiber/v2"
)

func Protected() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningKey: jwtware.SigningKey{
            JWTAlg: "HS256",
            Key:    []byte(os.Getenv("JWT_SECRET")),
        },
        TokenLookup:  "header:Authorization",
        AuthScheme:   "Bearer",
        ErrorHandler: jwtError,
    })
}

func jwtError(c *fiber.Ctx, err error) error {
    return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
        "message": "Unauthorized",
    })
}
  • Starting with imports, we are using "github.com/gofiber/contrib/jwt" packages and gave aliases as jwtware.

  • Here we have created a function Protected() that returns an HTTP handler. Inside this we are returning jwtware.New() handler which is used to create a new middleware instance for validating JWT

  • Here we pass different keys:

    • SigningKey: This specifies the algorithm and key for verifying JWT’s signature.

    • TokenLookup: Defines where the middleware should look for JWT

    • AuthScheme: It defines which scheme to use in Authorization

    • ErrorHandler: It is a custom function to handle errors. So whenever an unauthorized user access any routes which require authorization, this throws an error message: “Unauthorized “

  • In our case, we need two routes: Log in and Register as Public routes and Logout as Private route. How do we do that?

// routes.go

func SetupRoutes(app *fiber.App) {
    // Public routes
    app.Post("/api/register", controllers.Register)
    app.Post("/api/login", controllers.Login)

    app.Use(middleware.Protected())

    // Protected routes
    app.Post("/api/logout", controllers.Logout)
}
  • As you can see, to use any middleware, fiber provides a very handy method called Use() . So what will happen now is whatever routes defined after app.Use(middleware.Protected()) this will require a JWT Token to get access.

  • Let’s see it in action.

  1. Open Postman

  2. Try to logout directly

As you can see, Now it is throwing an error saying “Unauthorized“.

  1. Now login with valid credentials

  2. Copy the token and paste it inside the Authorization Bearer field and then try to logout.

a group of minions are standing next to each other and one of them is saying yay .


Code


Conclusion

  • In this article, we've explored how to implement secure user authentication in a Go application using JWTs and the Fiber framework.

  • We saw how to set up a project structure, connect to a MySQL database with GORM, and define essential routes and middleware.

  • We've built handlers for managing user registration, login, and protected routes. This approach not only enhances the security of your application but also provides a scalable framework for future development.

  • I hope you liked this article and learned something from it. If you did consider leaving a like and feedback in the comment section.

  • See you in the next one, until then….

Did you find this article valuable?

Support Dhruv Nakum by becoming a sponsor. Any amount is appreciated!