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

Search for a command to run...
How to Create Secure User Authentication in Go with JWT and Fiber

No comments yet. Be the first to comment.
Lessons from Code, Content, and Consistency

Reliability basics: Outbox pattern + why it matters

Introducing Kafka/Redpanda + move to event-driven workflow

Connect Orders ↔ Inventory (first working service-to-service flow)

Inventory service + gRPC + proto contracts

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.
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 .

cmd/server/main.go
controllers/auth_controller.go
database/db.go
middleware/middleware.go
models/user.go
user a model as we are dealing with the user’s authentication.routes/routes.go
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
.env
.env FileFirst 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
main.go and paste below codeimport "github.com/joho/godotenv"
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
}
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
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 {
// ...
}
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.

package models
type User struct {
Id
Name
Email
Password
}
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)
}
}
go run cmd/server/main.go

fiber package. To install run the following command inside your terminalgo get github.com/gofiber/fiber/v2
main.go to use it inside our applicationfunc 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)
}
// 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
}
func Register(c *fiber.Ctx) error {
var regReq types.RegisterRequest
if err := c.BodyParser(®Req); 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)
}
func Register(c *fiber.Ctx) error {
// ...
return c.JSON(fiber.Map{
"message": "user created successfully",
"data": user,
})
}
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)
}
}

// 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(),
})
}
}
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",
})
}
}
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,
})
}

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",
})
}



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"`
}


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.
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.
go get github.com/dgrijalva/jwt-go/v4
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",
})
}
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.”
// auth_controller.go
func Login(c *fiber.Ctx) error {
// ...
return c.JSON(fiber.Map{
"message": "success",
"token": token,
"data": loginReq,
})
}
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.
Open Postman
Try to logout directly

As you can see, Now it is throwing an error saying “Unauthorized“.
Now login with valid credentials

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


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….
