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:
- Golang, GORM, & Fiber: JWT Authentication
- Golang & MongoDB: JWT Authentication and Authorization
- Golang, SQLC, and PostgreSQL: JWT Access & Refresh Tokens
- API with Golang, Gin Gonic & MongoDB: Forget/Reset Password
- Build Golang gRPC Server and Client: Access & Refresh Tokens
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.
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.
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.
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 aConfig
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 theACCESS_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 theACCESS_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 the
REFRESH_TOKEN_PRIVATE_KEY
field, and similarly, add the corresponding base64-encoded public key asREFRESH_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.
That was a great explanation, Thanks a lot.
You are welcome! I’m glad you found it helpful.
There is no problem if we are reading from disk each time a user sign in, what is the impact of that in the application?
Could you please provide more details or clarify your question so that I can better understand the impact you are referring to?
Great Explanation, But I have one question it is always a good practice that writes/read token details to Redis for token revoke. We can also implement by using db for this although we query to db to check whether a user is exist or not. (why not store the token in DB). My question is
1. So why use the Redis cache mechanism to store token detail?
2. What is advantage of this approach over Database
I completely understand your point. You’re suggesting that since we already query the database to check if the user associated with the token exists, why not store the token details in the database as well, instead of using Redis? It’s a valid consideration.
In our project, we deliberately opted to use Redis as a cache for token details. The reason behind this choice is that JWTs are designed to be stateless, meaning they shouldn’t require round trips to the database. Since token revocation checks are performed frequently during authentication, using Redis as a cache can significantly improve the performance of token validation.
Additionally, Redis provides built-in features for setting expiration times on stored keys, which is not available in most databases. This is particularly advantageous for managing token expiration since tokens typically have a limited lifespan. Storing token details in Redis alone is often sufficient for implementing token revocation.
Furthermore, although querying the database to verify the user associated with the token adds an extra layer of security, it is not strictly necessary in most cases. The primary purpose of token revocation is to invalidate a token and prevent unauthorized access. Redis adequately serves this purpose without requiring additional database queries.
Thanks a lot for the full explanation. What is the best way to handle refresh token expiration as it only valid for 60m? ThanksF
There are two effective ways to handle refresh token expiration:
/api/auth/refresh
) for token refreshing./api/auth/refresh
) to handle refresh requests from the frontend. If you’re using Axios on the frontend, use Axios interceptors to automatically refresh the token when it expires before processing the original request. Here is an example on using Axios Axios interceptors to refresh the tokenConsider a scenario where user authentication is performed using JSON Web Tokens (JWT). It is essential for the system administrator to have the ability to log users out immediately, for example, as soon as this action is taken, one or multiple users should swiftly be logged out of the system or their access privileges should be altered. Considering that tokens are self-contained and are typically stored in cookies, and also that our access token has a one-hour expiration limit, what solution do you propose for this?
One way to achieve what you described is by storing the JWT metadata in Redis. By doing so, you can easily revoke the tokens when necessary.