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
Table of contents
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 initilizeddsn
(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 themysql
driver'sOpen
function to connect with ourdsn
string. The second parameter is for gorm configuration if you have any.This
gorm.Open
function returns two things:db
instance anderror
. So what we need to do is, first we need to create a globalgorm.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 isdb
inside theConnect
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 globalDB
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 modelUser
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 aapp
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(®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 anerror
of typeErrUnprocessableEntity
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 functionGenerateFromPassword
that takes a byte ofpassword
string as the first parameter andcost
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 encryptedpassword
.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 not0
, 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 ofDB
.
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
'sListen
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 ofDB
to see if the email is in the database. If it exists (we check this by seeing ifuser.Id
is not0
), 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 calledCompareHashAndPassword
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 asname
using an annotation.To achieve this, we need to make changes inside
types.go
anduser.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 ofPassword
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 calledNewWithClaims
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 inIssuer
as a string andExpiresAt
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 asjwtware
.Here we have created a function
Protected()
that returns an HTTP handler. Inside this we are returningjwtware.New()
handler which is used to create a new middleware instance for validating JWTHere 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 afterapp.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.
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….