In this comprehensive guide, you’ll learn how to properly refresh JSON Web Tokens (JWTs) using the RS256 algorithm and Redis for session storage. The integration of Redis will give us the ability to effortlessly revoke or invalidate the JWTs when necessary. The API will run on the high-performance Fiber web framework and leverage a PostgreSQL database for data storage.

What are Asymmetric keys? Asymmetric keys, also known as public-key cryptography, are types of cryptography where the encryption and decryption process uses two different keys. These keys are known as public and private keys.

The public key can be openly shared as it is utilized to encrypt information. The private key, however, is kept confidential by the owner and serves as the means for decrypting the data encrypted by the public key. This design ensures that only the intended recipient, who holds the private key, is able to access the original, unencrypted information.

What is the RS256 algorithm? RS256 is a specific type of asymmetric (public-key) algorithm for digital signatures, used in the JWT format. In RS256, a private key is used to sign the JWT, and the corresponding public key is used to verify the signature.

RS256 is a secure and widely used algorithm, but it is slower than some other digital signature algorithms, such as HS256 (HMAC with SHA-256), which uses symmetric keys.

More practice:

How to Properly Refresh JWTs for Authentication in Golang

Run the Golang + Fiber JWT Project Locally

  • Obtain the Golang + Fiber JWT project by downloading or cloning it from https://github.com/wpcodevo/golang-fiber-jwt-rs256, then open the source code in your favourite IDE or text editor.
  • Launch the PostgreSQL, Redis, and pgAdmin Docker containers by executing the command docker-compose up -d.
  • Open the integrated terminal in your IDE and run the command go install to install the project’s dependencies.
  • Start the Fiber HTTP server by running the command go run main.go.
  • Import the Golang_Fiber_JWT_RS256.postman_collection.json file into either Postman or Thunder Client VS Code extension and test the JWT authentication flow by sending HTTP requests to the Fiber API.

Flaws of using only JWTs for Authentication

JSON Web Tokens are widely used for managing authentication and authorization in web applications, yet they pose certain limitations and security risks when utilized without server-side sessions. Some of the key issues include:

  • Limited Token Revocation: JWTs do not provide a way to revoke or invalidate tokens that have been issued. This means that once a JWT is issued, it remains valid until it expires, which could pose a security risk if a user’s privileges change, or if the user needs to be logged out for security reasons.
  • Statelessness: JWTs are stateless, meaning that they don’t store any information on the server. This can be a security risk as the server has no way of knowing if a token has been revoked or if the information it contains is still valid.
  • Token tampering: Since JWTs are signed but not encrypted, an attacker can modify the contents of the JWT without detection, potentially giving them unauthorized access to sensitive information.

Solutions to Address the Flaws of JWTs

  • Server-side Sessions: A feasible solution is to store the JWT metadata in a persistence layer. This can be achieved using any preferred persistence layer, but Redis is highly recommended. Storing the JWT metadata will allow the server to track user activity, revoke tokens, and manage the user’s session.
  • Token Revocation: Implementing a token revocation mechanism, such as using a list of revoked tokens or using a time-limited token, can help address the issue of limited token revocation.
  • Token Rotation: Regularly rotating the tokens can help prevent long-term compromise of the token and improve security.

Bootstrap the Golang Project

Upon completing this project, your folder and file organization should closely match the example shown in the screenshot below.

Golang + Fiber JWT RS256 Final Project Structure

To start, navigate to a location on your machine or Desktop and create a folder named golang-fiber-jwt-rs256. Then, change into the newly created folder and initialize a Golang project by running the command go mod init. Please ensure to replace wpcodevo with your personal GitHub username.


mkdir golang-fiber-jwt-rs256
cd golang-fiber-jwt-rs256
go mod init github.com/wpcodevo/golang-fiber-jwt-rs256

With the project initialized, let’s install the required dependencies by running the following commands.


go get github.com/go-playground/validator/v10
go get -u github.com/gofiber/fiber/v2
go get -u github.com/golang-jwt/jwt/v4
go get github.com/redis/go-redis/v9
go get github.com/satori/go.uuid
go get github.com/spf13/viper
go get gorm.io/driver/postgres
go get -u gorm.io/gorm
go install github.com/cosmtrek/air@latest

  • validator – A robust and efficient tool for validating user-submitted data in Go projects.
  • fiber – Is a fast, lightweight, and flexible web framework for building blazingly-fast web applications in Go.
  • jwt – For encoding and decoding JSON Web Tokens (JWTs) in Golang.
  • go-redis – Is a Golang client library for Redis, an open-source, in-memory data structure store.
  • go.uuid – A library to generate and parse UUIDs in Golang.
  • viper – For managing configuration data in Golang applications.
  • postgres – Provides a low-level implementation of the database driver for PostgreSQL, allowing the GORM library to interact with PostgreSQL databases.
  • gorm – A Golang ORM that provides a high-level, convenient, and efficient way to interact with databases.
  • air – A library for live reloading Go applications during development.

Now that you’ve installed all the necessary dependencies, let’s proceed to build a basic Fiber HTTP server. The server will respond to GET requests made to the /api/healthchecker endpoint by returning a simple JSON object. This will serve as a starting point for further development and allow us to verify that our setup is correct.

main.go


package main

import (
	"log"

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

func main() {
	app := fiber.New()
	app.Use(logger.New())

	app.Get("/api/healthchecker", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusOK).JSON(fiber.Map{
			"status":  "success",
			"message": "How to Refresh Access Tokens the Right Way in Golang",
		})
	})

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

The code presented above sets up a new Fiber HTTP server by creating an instance of Fiber using fiber.New(). It then utilizes the GET method to define a route for handling GET requests made to the /api/healthchecker endpoint.

To run the Fiber HTTP server and see the JSON object in action, you can either use the air command or execute the code with go run main.go. Once the server is running, you can send a GET request to the URL http://localhost:8000/api/healthchecker to retrieve the JSON object.

Golang + Fiber JWT RS256 Test the Health Checker Route of the API

Setup Postgres, Redis, and pgAdmin with Docker

In this step, you’ll set up PostgreSQL, Redis, and pgAdmin with Docker. To do this, create a docker-compose.yml file in the root directory and include the following Docker Compose configurations.

docker-compose.yml


version: '3'
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"
  redis:
    image: redis:alpine
    container_name: redis
    ports:
      - '6379:6379'
    volumes:
      - redisDB:/data
volumes:
  redisDB:
  progresDB:

To supply the credentials needed by the Postgres and Redis Docker images, create an app.env file in the root directory and add the following environment variables. This will allow the images to access the required information for their proper functioning.

app.env


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

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

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

PORT=8000
CLIENT_ORIGIN=http://localhost:3000

REDIS_URL=localhost:6379

ACCESS_TOKEN_PRIVATE_KEY=
ACCESS_TOKEN_PUBLIC_KEY=
ACCESS_TOKEN_EXPIRED_IN=15m
ACCESS_TOKEN_MAXAGE=15


REFRESH_TOKEN_PRIVATE_KEY=
REFRESH_TOKEN_PUBLIC_KEY=
REFRESH_TOKEN_EXPIRED_IN=60m
REFRESH_TOKEN_MAXAGE=60

Now that you have the environment variables set, open the integrated terminal in your IDE and execute the command docker-compose up -d to launch the Postgres, Redis, and pgAdmin containers. Then, access the Docker Desktop application to view the status of the containers that are currently running.

View the Status of the Running Postgres, Redis, and pgAdmin Containers in the Docker Desktop Application

Create the Database Model

In this section, you’ll create a GORM model to represent the structure of the underlying SQL table. The fields within the model will match the columns of the table. To do this, create a folder named “models” in the root directory. Then, within this folder, create a file named user.model.go and include the following module definitions.

models/user.model.go


package models

import (
	"time"

	"github.com/go-playground/validator/v10"
	uuid "github.com/satori/go.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:idx_email;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,omitempty"`
}

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
}

To validate the incoming data during the sign-up and sign-in processes, we will make use of the “SignUpInput” and “SignInInput” structs respectively. These structs will ensure the integrity of the data being received.

The FilterUserRecord function will utilize the UserResponse struct to filter the records returned by GORM and eliminate sensitive information, such as hashed passwords, ensuring that only non-sensitive details are included in the JSON response.

For the sake of simplicity, we’ll leave the “SignUpInput“, “SignInInput“, and “UserResponse” structs in the models/user.model.go file instead of creating a separate file, schemas/user.schema.go, for them.

Connect to the Redis and Postgres Containers

With the Postgres and Redis servers running in their respective Docker containers, we can now proceed to create utility functions for establishing a connection between the servers and the application.

  • LoadConfig – This helper function will load the environment variables stored in the “app.env” file and unmarshal the content into a Config struct.
  • ConnectRedis – This helper function will connect the Redis server to the Go application.
  • ConnectDB – This helper function will connect the Postgres server to the Go application.

Load the Environment Variables

Here, you’ll create a helper function that will utilize the Viper package to load the environment variables from the “app.env” file, parse its content and store the values in a Config struct, making it easily accessible throughout the application.

To achieve this, create a folder named “initializers” in the root directory and within the folder, create a loadEnv.go file. Then, add the following code to the file.

initializers/loadEnv.go


package initializers

import (
	"time"

	"github.com/spf13/viper"
)

type Config 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"`
	ServerPort     string `mapstructure:"PORT"`

	ClientOrigin string `mapstructure:"CLIENT_ORIGIN"`
	RedisUri     string `mapstructure:"REDIS_URL"`

	AccessTokenPrivateKey  string        `mapstructure:"ACCESS_TOKEN_PRIVATE_KEY"`
	AccessTokenPublicKey   string        `mapstructure:"ACCESS_TOKEN_PUBLIC_KEY"`
	RefreshTokenPrivateKey string        `mapstructure:"REFRESH_TOKEN_PRIVATE_KEY"`
	RefreshTokenPublicKey  string        `mapstructure:"REFRESH_TOKEN_PUBLIC_KEY"`
	AccessTokenExpiresIn   time.Duration `mapstructure:"ACCESS_TOKEN_EXPIRED_IN"`
	RefreshTokenExpiresIn  time.Duration `mapstructure:"REFRESH_TOKEN_EXPIRED_IN"`
	AccessTokenMaxAge      int           `mapstructure:"ACCESS_TOKEN_MAXAGE"`
	RefreshTokenMaxAge     int           `mapstructure:"REFRESH_TOKEN_MAXAGE"`
}

func LoadConfig(path string) (config Config, err error) {
	viper.AddConfigPath(path)
	viper.SetConfigType("env")
	viper.SetConfigName("app")

	viper.AutomaticEnv()

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

	err = viper.Unmarshal(&config)
	return
}

Connect to the Redis Server

In this step, you’ll create a ConnectRedis helper function to establish a connection between the Redis server and the application. Upon successful connection, the Redis client will be used to insert a message, “How to Refresh Access Tokens the Right Way in Golang“, with the key “test” into the Redis database

At a later stage, we’ll utilize the Redis client to retrieve the message in the context handler of the health checker route and return it in the JSON response. To set this up, create a file named connectRedis.go within the “initializers” directory and add the following code.

initializers/connectRedis.go


package initializers

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
)

var (
	RedisClient *redis.Client
	ctx         context.Context
)

func ConnectRedis(config *Config) {
	ctx = context.TODO()

	RedisClient = redis.NewClient(&redis.Options{
		Addr: config.RedisUri,
	})

	if _, err := RedisClient.Ping(ctx).Result(); err != nil {
		panic(err)
	}

	err := RedisClient.Set(ctx, "test", "How to Refresh Access Tokens the Right Way in Golang", 0).Err()
	if err != nil {
		panic(err)
	}

	fmt.Println("✅ Redis client connected successfully...")
}

Connect to the Postgres Server

Here, you will utilize both GORM and the Postgres driver to establish a connection pool between the Postgres server and the application. Once a successful connection is established, the DB.AutoMigrate() function will be evoked to apply the GORM schema to the Postgres database.

initializers/connectDB.go


package initializers

import (
	"fmt"
	"log"
	"os"

	"github.com/wpcodevo/golang-fiber-jwt-rs256/models"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var DB *gorm.DB

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

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

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

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

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

Generate the Private and Public Keys

We have reached the point where we can now generate the asymmetric keys, comprising both a private and a public key. To proceed, kindly follow the instructions below.

  • To begin, visit this Online RSA Key Generator website to generate both private and public keys.
  • Change the key size to 4096 bits on the website and click on the blue “Generate New Keys” button. This process may take a moment, so please be patient while generating the keys.
  • Once the keys have been successfully generated, copy the private key. Then, visit https://www.base64encode.org/ to convert it to base64 format. Finally, copy the base64-encoded key and add it to the app.env file as the ACCESS_TOKEN_PRIVATE_KEY value.
  • Similarly, copy the corresponding public key of the private key from the “Online RSA Key Generator” website. Then, use the https://www.base64encode.org/ website to convert it to base64 format. Finally, copy the base64-encoded key and add it to the app.env file as the ACCESS_TOKEN_PUBLIC_KEY value.
  • To generate the private and public keys for the refresh token, repeat the process you followed for the access token. Convert the private key to base64 format and add it to the “app.env” file as the value of theREFRESH_TOKEN_PRIVATE_KEY field, and similarly, add the corresponding base64-encoded public key as REFRESH_TOKEN_PUBLIC_KEY.

We had to convert the private and public keys to base64 formats to prevent any unnecessary warnings in the terminal when Docker Compose retrieves the pgAdmin and Postgres credentials from the app.env file during the reading process.

By successfully following the aforementioned steps, your “app.env” file should now be similar to the one below. If you encountered difficulties generating the private and public keys, you can use the following pre-defined environment variables.

app.env


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

PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=password123

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

PORT=8000
CLIENT_ORIGIN=http://localhost:3000

REDIS_URL=localhost:6379

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDWEFJQkFBS0JnUUNOQkQwaDRPRGVTd3E0WnBIMmpBVVd5RFV1V01sb1ZlT3RSQm8yOW9TcVFJazVKSlJaCjR6SkZHZ2k0a1d4amJZemY2ZEMyRWJIdFIrbTYyM3RsV05iS3VJcklYQnZNTnVtS0RMeG9qN3VOVUVWclFVRVkKVmE5U25OUzFsM1U4S1FRbTFYaWFMamVIYWs3QzhYZi9wUEFNalhzcHdQNE1lNktpVE1RcU5ESE4zUUlEQVFBQgpBb0dBU2EwNlIzWVg1dXlzT0RZVzh3cXJLZ0VHa0NXQmJZcmFmcytESnM1YitCdnAxam8vYkV0aEcydUR2UEwxCi8yamdYcWpxREFab3dRRitvOHRDeUd2SEpLMWRURjRpcVRNcmQ3U1dlRk5WYkx3QkpLOXFiOW45NFBTZ1BuV1gKY2JFVXN4VEhLUHRwMlpOWURXcUsyWlFaVlpKRkJzTXZndVNyYlRjcFJia0drSjBDUVFEeEdiNVJRc1hNc3Z4YQpWOFFTUzZpT2J1M1BCVDRqazRJMnB6TzlMR2RwNDNVN0ZPd2QxdjVVbTRvdFpPaFVpWC9BdTRGTDdkS1A0RFdOCm5kRnBSR1lYQWtFQWxic2x3UlBtT3UxZ0VWOUtRakloWVZjMDB0ZlloSStERXdJMEJGVWtnRWNzZGFCRFhSM0YKYXdJL3o4Mlc0bklRN3ZpaXROSDZIYWVacXdid2RRR1lLd0pCQU00T1RXelAzNU5LS1lqZzE2ODNRRkN6RjhYRgoya3kzaGlORmxWK0pjcnk1N0hoWk1rOXlicDFLN2JaTU5wQUJqOURkcit4L3ptU3VuN1p2K2dpNHIzTUNRRVduCnduQ0g2VnNRZ3RpU0UrR25vSS9BR2ZyY0h3WE1IWllDT0dDcm0wZHgxT1VEb1ZMNFBwY0JmTjRYTGxJNTdsYTkKcERPcVcwamdaMFNBL2V2d3lmRUNRRW5WVnRmbThGdDZoSWFPOXNwY2VSRlhUeURBL2V1d1hhQVhubXk4RWZXZQpSaVZtSFhDVzlaSm9DbVpjbU5zY1o1Vm8zYzRMYzl2cTQyMXRKaXVRRGUwPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlHZk1BMEdDU3FHU0liM0RRRUJBUVVBQTRHTkFEQ0JpUUtCZ1FDTkJEMGg0T0RlU3dxNFpwSDJqQVVXeURVdQpXTWxvVmVPdFJCbzI5b1NxUUlrNUpKUlo0ekpGR2dpNGtXeGpiWXpmNmRDMkViSHRSK202MjN0bFdOYkt1SXJJClhCdk1OdW1LREx4b2o3dU5VRVZyUVVFWVZhOVNuTlMxbDNVOEtRUW0xWGlhTGplSGFrN0M4WGYvcFBBTWpYc3AKd1A0TWU2S2lUTVFxTkRITjNRSURBUUFCCi0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
ACCESS_TOKEN_EXPIRED_IN=15m
ACCESS_TOKEN_MAXAGE=15


REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDWFFJQkFBS0JnUURSM0cyZkJYWDFiOGVRWXZKU2NwNSsrYzRRMThYTWRySVNFZmtSNVd5dzJUOS94eUQ5CjIxUUV2dmp2Y1BRdmlGd1hLb3VwYnFNdVFPRDFhUm9GdkJRN1J2MndUWjh5REE2YmYzcEo3NXJSYTA1UUpDaEYKL1pkR2FIZ2JYK0FuYXlzdUovNHR1OTdEcGFFMmpWVE55K05iMm1kb2ZSZ0RxbHMxbG1kY1BBY1B2UUlEQVFBQgpBb0dBT3Erd3ZCS1QzUkhvTmRsbHVHYXpLZ0VEZmpSSTdSZVlUbk5XT29uMDdqT2lqVUlMV05JMzJhZnFCMU9JCkJhN1ZTZWtzNnpHMFVsLzBTTXllYVZJaU9idktEdVdnb0MrN2ZOSUhWQUdWbkpwTXl5WXhyMXE4MC9vdWNiMlUKL1AybUs5UnNORmJORm8xOTlra1VoeG1rdjV5RUdJS0RsYmJjV3lYd0xHN1NXU0VDUVFEcERXRms5ZUVnTGFHQgpXWElQMG0yWHJQeFdRdFZ1WTZCRmRVSy9LS2FPL2NQMjVxV3JZL0R1MDhsaUdvY2NkSjBwZmtwU2tCb3hTUTVOClp4T0k5bldsQWtFQTVvWjFxV2VuS0YzbkdhRlBpdzR3RDVBMXNENVhYa3V0djl0cUVTcXQ1a0tuZG81OWdROUMKMFJ5aGQxZ3FoY0M0NC9TWmZEUURwTU5tTGxoRUljRUdPUUpBQmJZRU92c2poeXhYRnRwZ1J5NzY3SXFhckdwNgozSGVvaDhzMTFZVmpmNEdNZWRKeElPQVVHV1lyT3pJM09XVktMS2dobmlCVjQvdE1WRzFBTjAwQzJRSkJBSVJ5CmdLdmlhQUlqWWFJeU1sZDh3VlJQME9rQUNJYWZDS2NRMDdJbFNXRGdyd0xJLzRibFU4aDlvSy9ITWpkQzhYZlgKazAvdk9xQ3h1OFdvNVF4WHNORUNRUURHVFJtSElsS2g1QzJ6dUhlL1R6ajFjZjNKUkRsbVpQcXhNN1NuNTJpYwoxS0p3aHFrd1IxR0ZKZHpUVitpZEx1RlN6QUIvRHNjejFhTTRJUlBYSXVmNgotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlHZk1BMEdDU3FHU0liM0RRRUJBUVVBQTRHTkFEQ0JpUUtCZ1FEUjNHMmZCWFgxYjhlUVl2SlNjcDUrK2M0UQoxOFhNZHJJU0Vma1I1V3l3MlQ5L3h5RDkyMVFFdnZqdmNQUXZpRndYS291cGJxTXVRT0QxYVJvRnZCUTdSdjJ3ClRaOHlEQTZiZjNwSjc1clJhMDVRSkNoRi9aZEdhSGdiWCtBbmF5c3VKLzR0dTk3RHBhRTJqVlROeStOYjJtZG8KZlJnRHFsczFsbWRjUEFjUHZRSURBUUFCCi0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
REFRESH_TOKEN_EXPIRED_IN=60m
REFRESH_TOKEN_MAXAGE=60

Sign and Verify JWTs with the Asymmetric Keys

Now that you have obtained the private and public keys, let’s move forward by creating utility functions to sign and verify JSON Web Tokens. The private keys will be utilized to sign the access and refresh tokens, while the public keys will be used for verifying their authenticity.

To begin, create a folder named “utils” within the “src” directory, and within the folder, create a file named token.go. Then, include the following dependencies in the file.

utils/token.go


import (
	"encoding/base64"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v4"
	uuid "github.com/satori/go.uuid"
)

type TokenDetails struct {
	Token     *string
	TokenUuid string
	UserID    string
	ExpiresIn *int64
}

Sign the JWT with the Private Key

This helper function will use the private keys to sign JSON Web Tokens through the RS256 algorithm. When executed, it will first convert the base64-encoded private key to its original string form and then utilize it to sign the JWT.

utils/token.go


func CreateToken(userid string, ttl time.Duration, privateKey string) (*TokenDetails, error) {
	now := time.Now().UTC()
	td := &TokenDetails{
		ExpiresIn: new(int64),
		Token:     new(string),
	}
	*td.ExpiresIn = now.Add(ttl).Unix()
	td.TokenUuid = uuid.NewV4().String()
	td.UserID = userid

	decodedPrivateKey, err := base64.StdEncoding.DecodeString(privateKey)
	if err != nil {
		return nil, fmt.Errorf("could not decode token private key: %w", err)
	}
	key, err := jwt.ParseRSAPrivateKeyFromPEM(decodedPrivateKey)

	if err != nil {
		return nil, fmt.Errorf("create: parse token private key: %w", err)
	}

	atClaims := make(jwt.MapClaims)
	atClaims["sub"] = userid
	atClaims["token_uuid"] = td.TokenUuid
	atClaims["exp"] = td.ExpiresIn
	atClaims["iat"] = now.Unix()
	atClaims["nbf"] = now.Unix()

	*td.Token, err = jwt.NewWithClaims(jwt.SigningMethodRS256, atClaims).SignedString(key)
	if err != nil {
		return nil, fmt.Errorf("create: sign token: %w", err)
	}

	return td, nil
}

In a previous article titled Golang, GORM, & Fiber: JWT Authentication, we implemented JWT authentication using a single token and the HS256 algorithm. Although the HS256 algorithm is faster, it presents security vulnerabilities that must be taken into consideration.

To overcome the security shortcomings of the HS256 algorithm, we will create and sign two distinct JWTs: an access token and a refresh token. Subsequently, we will define a struct to store the metadata of these tokens, such as their expiration time and unique identifier (UUID) used to associate the token with the user.


type TokenDetails struct {
	Token     *string
	TokenUuid string
	UserID    string
	ExpiresIn *int64
}

The expiration time and UUIDs are crucial components in our implementation as they will be used when saving the token metadata in Redis. Using Redis as a session storage solution for the server allows us to monitor the user’s actions, validate the tokens, and revoke tokens as necessary. This helps ensure the security and integrity of the system.

Verify the JWT with the Public Key

This utility function will utilize the public keys to authenticate JSON Web Tokens (JWTs). Upon execution, it will first decode the base64-encoded public key back to its original string form and then use it to verify the JWT and extract the corresponding payload.

utils/token.go


func ValidateToken(token string, publicKey string) (*TokenDetails, error) {
	decodedPublicKey, err := base64.StdEncoding.DecodeString(publicKey)
	if err != nil {
		return nil, fmt.Errorf("could not decode: %w", err)
	}

	key, err := jwt.ParseRSAPublicKeyFromPEM(decodedPublicKey)

	if err != nil {
		return nil, fmt.Errorf("validate: parse key: %w", err)
	}

	parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
			return nil, fmt.Errorf("unexpected method: %s", t.Header["alg"])
		}
		return key, nil
	})

	if err != nil {
		return nil, fmt.Errorf("validate: %w", err)
	}

	claims, ok := parsedToken.Claims.(jwt.MapClaims)
	if !ok || !parsedToken.Valid {
		return nil, fmt.Errorf("validate: invalid token")
	}

	return &TokenDetails{
		TokenUuid: fmt.Sprint(claims["token_uuid"]),
		UserID:    fmt.Sprint(claims["sub"]),
	}, nil
}

Implement the JWT Authentication

With the JWT utility functions in place, it’s time to put them into action by creating Fiber context handlers. These handlers will be responsible for managing the authentication process, including signing up users, logging them in, logging them out, and protecting sensitive routes.

Create the Account Registration Route Handler

Let’s create a Fiber context handler for user registration. This function will be triggered when a POST request is made to the /api/auth/register endpoint.

The function will first extract the user data from the request body and validate it using the models.ValidateStruct() method. In case of validation errors, the function will immediately return a 400 Bad Request response with the respective error messages to the client.

Then, the function will verify that the payload.Password and payload.PasswordConfirm fields match. If they don’t, a 400 Bad Request response with the message “Passwords do not match” will be sent to the client.

controllers/auth.controller.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.StatusBadGateway).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)}})
}

After validation, the function will proceed to hash the plain-text password using the bcrypt.GenerateFromPassword() function. Then, it will insert the new user into the database using GORM.

In case a user with the same email address already exists in the database, the function will return a 409 Conflict response to the client. However, if the user was added successfully, the models.FilterUserRecord() method will be called to selectively remove any sensitive information, such as the hashed password, from the returned record. The remaining, non-sensitive details will then be included in the JSON response.

Create the Account Login Route Handler

Here, let’s create a Fiber context handler to process user sign-in requests. This handler will be triggered by a POST request to the /api/auth/login endpoint.

Upon execution, the function will first extract the user credentials from the request body and validate them using the models.SignInInput struct. In case of any validation errors, it will immediately return a 400 Bad Request response to the client with the error messages.

Then, the function will query the database using GORM’s DB.First() method to check if a user with the provided email address exists. If a match is found, the function will utilize bcrypt.CompareHashAndPassword() method to compare the provided password with the hashed password stored in the database for that user.

controllers/auth.controller.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(fiber.Map{"status": "fail", "errors": errors})
	}

	message := "Invalid email or password"

	var user models.User
	err := initializers.DB.First(&user, "email = ?", strings.ToLower(payload.Email)).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
		} else {
			return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})

		}
	}

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

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

	accessTokenDetails, err := utils.CreateToken(user.ID.String(), config.AccessTokenExpiresIn, config.AccessTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	refreshTokenDetails, err := utils.CreateToken(user.ID.String(), config.RefreshTokenExpiresIn, config.RefreshTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	ctx := context.TODO()
	now := time.Now()

	errAccess := initializers.RedisClient.Set(ctx, accessTokenDetails.TokenUuid, user.ID.String(), time.Unix(*accessTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errAccess.Error()})
	}

	errRefresh := initializers.RedisClient.Set(ctx, refreshTokenDetails.TokenUuid, user.ID.String(), time.Unix(*refreshTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errRefresh.Error()})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "access_token",
		Value:    *accessTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.AccessTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

	c.Cookie(&fiber.Cookie{
		Name:     "refresh_token",
		Value:    *refreshTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.RefreshTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

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

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "access_token": accessTokenDetails.Token})
}

Once the validation process is successfully completed, the utils.CreateToken() function will be called to sign both the access and refresh tokens using their respective private keys.

The metadata of each token will then be securely stored in the Redis database through the use of the RedisClient.Set() method. Upon successful storage, the tokens will be included in the response as HTTP-only cookies and a copy of the access token will be sent in the JSON object.

This will enable the user to include the access token in the Authorization header as a Bearer token for future requests that require authentication.

Create the Refresh Token Route Handler

Let’s now create a Fiber context handler to manage the renewal of access tokens once they have expired. This handler will be triggered whenever a GET request is sent to the endpoint /api/auth/refresh.

When evoked, the function will first retrieve the refresh token from the Cookies object and then proceed to validate its authenticity using the utils.ValidateToken() method. Upon successful validation, the payload will be extracted and assigned to the tokenClaims variable.

Next, the RedisClient.Get() method will be evoked to check the existence of the token’s metadata in the Redis database, using the TokenUuid present in the tokenClaims struct as the key.

If the token’s metadata was found in the Redis database, a query will be made to the Postgres database to check if the user associated with the token still exists.

controllers/auth.controller.go


func RefreshAccessToken(c *fiber.Ctx) error {
	message := "could not refresh access token"

	refresh_token := c.Cookies("refresh_token")

	if refresh_token == "" {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	config, _ := initializers.LoadConfig(".")
	ctx := context.TODO()

	tokenClaims, err := utils.ValidateToken(refresh_token, config.RefreshTokenPublicKey)
	if err != nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	userid, err := initializers.RedisClient.Get(ctx, tokenClaims.TokenUuid).Result()
	if err == redis.Nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	var user models.User
	err = initializers.DB.First(&user, "id = ?", userid).Error

	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": "the user belonging to this token no logger exists"})
		} else {
			return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})

		}
	}

	accessTokenDetails, err := utils.CreateToken(user.ID.String(), config.AccessTokenExpiresIn, config.AccessTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	now := time.Now()

	errAccess := initializers.RedisClient.Set(ctx, accessTokenDetails.TokenUuid, user.ID.String(), time.Unix(*accessTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errAccess.Error()})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "access_token",
		Value:    *accessTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.AccessTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

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

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "access_token": accessTokenDetails.Token})
}

Upon successful completion of all verification steps, a new access token will be generated and its metadata will be stored in the Redis database. The token will then be included in the response, both as an HTTP-only cookie and within the JSON object.

Create the Logout Route Handler

Finally, let’s create a route handler to manage user logouts. This handler will be triggered whenever a GET request is sent to the endpoint /api/auth/logout.

When evoked, the function will retrieve the refresh token from the Cookies object and validate it to extract its payload. The metadata belonging to both the access and refresh tokens will then be deleted from the Redis database using the RedisClient.Del() function.

This ensures that even if an attacker gains access to the tokens, they will be rendered useless as their metadata has been removed from the Redis database.

controllers/auth.controller.go


func LogoutUser(c *fiber.Ctx) error {
	message := "Token is invalid or session has expired"

	refresh_token := c.Cookies("refresh_token")

	if refresh_token == "" {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	config, _ := initializers.LoadConfig(".")
	ctx := context.TODO()

	tokenClaims, err := utils.ValidateToken(refresh_token, config.RefreshTokenPublicKey)
	if err != nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	access_token_uuid := c.Locals("access_token_uuid").(string)
	_, err = initializers.RedisClient.Del(ctx, tokenClaims.TokenUuid, access_token_uuid).Result()
	if err != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	expired := time.Now().Add(-time.Hour * 24)
	c.Cookie(&fiber.Cookie{
		Name:    "access_token",
		Value:   "",
		Expires: expired,
	})
	c.Cookie(&fiber.Cookie{
		Name:    "refresh_token",
		Value:   "",
		Expires: expired,
	})
	c.Cookie(&fiber.Cookie{
		Name:    "logged_in",
		Value:   "",
		Expires: expired,
	})
	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success"})
}

Finally, the function will send expired cookies to the user’s API client or browser, effectively deleting the existing cookies. As a result, the user will be prompted to log in once again in order to access protected routes.

Complete Route Handlers

controllers/auth.controller.go


package controllers

import (
	"context"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/redis/go-redis/v9"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/initializers"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/models"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/utils"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

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.StatusBadGateway).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(fiber.Map{"status": "fail", "errors": errors})
	}

	message := "Invalid email or password"

	var user models.User
	err := initializers.DB.First(&user, "email = ?", strings.ToLower(payload.Email)).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
		} else {
			return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})

		}
	}

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

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

	accessTokenDetails, err := utils.CreateToken(user.ID.String(), config.AccessTokenExpiresIn, config.AccessTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	refreshTokenDetails, err := utils.CreateToken(user.ID.String(), config.RefreshTokenExpiresIn, config.RefreshTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	ctx := context.TODO()
	now := time.Now()

	errAccess := initializers.RedisClient.Set(ctx, accessTokenDetails.TokenUuid, user.ID.String(), time.Unix(*accessTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errAccess.Error()})
	}

	errRefresh := initializers.RedisClient.Set(ctx, refreshTokenDetails.TokenUuid, user.ID.String(), time.Unix(*refreshTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errRefresh.Error()})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "access_token",
		Value:    *accessTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.AccessTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

	c.Cookie(&fiber.Cookie{
		Name:     "refresh_token",
		Value:    *refreshTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.RefreshTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

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

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "access_token": accessTokenDetails.Token})
}

func RefreshAccessToken(c *fiber.Ctx) error {
	message := "could not refresh access token"

	refresh_token := c.Cookies("refresh_token")

	if refresh_token == "" {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	config, _ := initializers.LoadConfig(".")
	ctx := context.TODO()

	tokenClaims, err := utils.ValidateToken(refresh_token, config.RefreshTokenPublicKey)
	if err != nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	userid, err := initializers.RedisClient.Get(ctx, tokenClaims.TokenUuid).Result()
	if err == redis.Nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	var user models.User
	err = initializers.DB.First(&user, "id = ?", userid).Error

	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": "the user belonging to this token no logger exists"})
		} else {
			return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})

		}
	}

	accessTokenDetails, err := utils.CreateToken(user.ID.String(), config.AccessTokenExpiresIn, config.AccessTokenPrivateKey)
	if err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	now := time.Now()

	errAccess := initializers.RedisClient.Set(ctx, accessTokenDetails.TokenUuid, user.ID.String(), time.Unix(*accessTokenDetails.ExpiresIn, 0).Sub(now)).Err()
	if errAccess != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"status": "fail", "message": errAccess.Error()})
	}

	c.Cookie(&fiber.Cookie{
		Name:     "access_token",
		Value:    *accessTokenDetails.Token,
		Path:     "/",
		MaxAge:   config.AccessTokenMaxAge * 60,
		Secure:   false,
		HTTPOnly: true,
		Domain:   "localhost",
	})

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

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "access_token": accessTokenDetails.Token})
}

func LogoutUser(c *fiber.Ctx) error {
	message := "Token is invalid or session has expired"

	refresh_token := c.Cookies("refresh_token")

	if refresh_token == "" {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": message})
	}

	config, _ := initializers.LoadConfig(".")
	ctx := context.TODO()

	tokenClaims, err := utils.ValidateToken(refresh_token, config.RefreshTokenPublicKey)
	if err != nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	access_token_uuid := c.Locals("access_token_uuid").(string)
	_, err = initializers.RedisClient.Del(ctx, tokenClaims.TokenUuid, access_token_uuid).Result()
	if err != nil {
		return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	expired := time.Now().Add(-time.Hour * 24)
	c.Cookie(&fiber.Cookie{
		Name:    "access_token",
		Value:   "",
		Expires: expired,
	})
	c.Cookie(&fiber.Cookie{
		Name:    "refresh_token",
		Value:   "",
		Expires: expired,
	})
	c.Cookie(&fiber.Cookie{
		Name:    "logged_in",
		Value:   "",
		Expires: expired,
	})
	return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success"})
}

Create the JWT Middleware Guard

Now that we have created all the authentication route handlers, let’s create a JWT middleware guard to ensure that only authorized users with valid JWTs, either in the Authorization header or Cookies object, can access protected routes.

To implement this, let’s create a “middleware” folder within the “src” directory. Within the newly created folder, add a file named deserialize-user.go and insert the following code

middleware/deserialize-user.go


package middleware

import (
	"context"
	"strings"

	"github.com/gofiber/fiber/v2"
	"github.com/redis/go-redis/v9"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/initializers"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/models"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/utils"
	"gorm.io/gorm"
)

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

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

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

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

	tokenClaims, err := utils.ValidateToken(access_token, config.AccessTokenPublicKey)
	if err != nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": err.Error()})
	}

	ctx := context.TODO()
	userid, err := initializers.RedisClient.Get(ctx, tokenClaims.TokenUuid).Result()
	if err == redis.Nil {
		return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "fail", "message": "Token is invalid or session has expired"})
	}

	var user models.User
	err = initializers.DB.First(&user, "id = ?", userid).Error

	if err == gorm.ErrRecordNotFound {
		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))
	c.Locals("access_token_uuid", tokenClaims.TokenUuid)

	return c.Next()
}

The JWT middleware will first attempt to retrieve the token from the Authorization header. If it is not found, it will check the Cookies object for the access_token key. If the token is not present in either of these locations, a 401 Unauthorized response with the message “You are not logged in” will be sent to the client.

However, if the token is present, the utils.ValidateToken() function will be called to verify the token’s authenticity and extract the claims. Upon successful validation, the middleware will then consult the Redis database using the “TokenUuid” from the token’s payload to retrieve the token’s metadata.

If the token metadata is found in the Redis database, it indicates that the user’s session is still active.

Subsequently, the DB.First() function from GORM will be executed to confirm the existence of the user associated with the token within the database. if the user is found, the record obtained from the query will be stored in the Fiber context object, making it accessible to succeeding route handlers.

Retrieve the Authentication User

Next, let’s create a Fiber context handler to retrieve the user’s record from the context object and return it to the client in a JSON format. To ensure the security of this route, it will be protected by the JWT middleware guard, allowing only authorized users to access the information.

To do this, create a user.controller.go file within the “controllers” folder and add the following code.

controllers/user.controller.go


package controllers

import (
	"github.com/gofiber/fiber/v2"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/models"
)

func GetMe(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}})
}

Register the Routes and Add CORS to the Server

Finally, we will evoke the ConnectDB and ConnectRedis functions to establish connections with the Postgres and Redis servers, respectively. Furthermore, we will define routes for the Fiber context handlers and configure the server to handle Cross-Origin Resource Sharing (CORS) requests.

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

main.go


package main

import (
	"context"
	"fmt"
	"log"

	"github.com/redis/go-redis/v9"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/logger"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/controllers"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/initializers"
	"github.com/wpcodevo/golang-fiber-jwt-rs256/middleware"
)

func init() {
	config, err := initializers.LoadConfig(".")
	if err != nil {
		log.Fatalln("Failed to load environment variables! \n", err.Error())
	}
	initializers.ConnectDB(&config)
	initializers.ConnectRedis(&config)
}

func main() {
	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,
	}))

	micro.Route("/auth", func(router fiber.Router) {
		router.Post("/register", controllers.SignUpUser)
		router.Post("/login", controllers.SignInUser)
		router.Get("/logout", middleware.DeserializeUser, controllers.LogoutUser)
		router.Get("/refresh", controllers.RefreshAccessToken)
	})

	micro.Get("/users/me", middleware.DeserializeUser, controllers.GetMe)

	ctx := context.TODO()
	value, err := initializers.RedisClient.Get(ctx, "test").Result()

	if err == redis.Nil {
		fmt.Println("key: test does not exist")
	} else if err != nil {
		panic(err)
	}

	micro.Get("/healthchecker", func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusOK).JSON(fiber.Map{
			"status":  "success",
			"message": value,
		})
	})

	micro.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"))
}

Conclusion

Congratulations, you made it! I am proud of your efforts. The full source code for the Golang JWT authentication project can be found on GitHub.

Throughout this article, you have gained an understanding of how to properly refresh JSON Web Tokens using the RS256 algorithm and Redis for session storage. The API is equipped with all the essential authentication features, including user registration, login, logout, and protection of private routes.