Mobile OTP based authentication in golang
In this post, we will develop a mobile OTP-based authentication API in Golang.
Tech stack
Golang
Go (or Golang) is an open-source programming language developed by Google. It emphasizes simplicity, efficiency, and concurrency support. Go is widely used for web development, system programming, and cloud-native applications.
Gofiber
GoFiber is a lightweight and fast web framework written in Go (Golang). It is built on top of Fasthttp, making it an efficient choice for building high-performance APIs and web applications. GoFiber offers features like middleware support, routing, and context handling.
MongoDB
MongoDB is a NoSQL, document-oriented database, known for its flexibility and scalability. It stores data in JSON-like BSON format, enabling easy manipulation and indexing. MongoDB is widely used for handling large-scale, unstructured, and real-time data in modern applications.
Twilio
Twilio is a cloud communications platform that enables developers to integrate messaging, voice, and video functionalities into their applications using APIs. It simplifies the process of building communication features such as SMS, MMS, phone calls, and more, allowing businesses to engage with their customers programmatically.
Create a new directory auth
and init go modules inside the project.
go mod init auth
Install required packages
go get github.com/gofiber/fiber/v2
go get -u github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
go get github.com/twilio/twilio-go
go get go.mongodb.org/mongo-driver/mongo
Create folder structure for our project
Project
├── README.md
├── config
│ └── config.go
├── database
│ ├── connection.go
│ └── database.go
├── go.mod
├── go.sum
├── handler
│ └── auth.go
├── main.go
├── middleware
│ └── auth.go
├── model
│ └── user.go
├── router
│ └── router.go
├── schema
│ ├── auth.go
│ └── response.go
└── util
├── twilio.go
└── user.go
config
folder contains configurations i.e. env variable configuration.database
folder contains database connections and types related to db i.e. mongodb driver setup.handler
folder contains route handler i.e. register handler, login handler etc...middleware
folder contains route middleware that runs before or after any handler executes i.e. auth jwt validation middleware.model
contains MongoDB schema i.e. User schema.router
contains route initialization and maps API routes to specific route handlers i.e. auth routes.schema
contains application schema i.e. incoming request body schema.util
contains reusable database query and application helper function i.e. Find user by phone, send sms etc....env
contains environment variables i.e. Twilio api-key, mongodb-uri etc...copy all
.env.example
and paste it inside.env
.main.go
is the entry point of the Go application.
Next, I will show you the required routes for this project.
- /api/auth
- /register
- /login
- /verify_otp
- /resend_otp
- /me
main.go
package main
import (
"auth/database"
"auth/router"
"log"
)
func main() {
app := router.New()
err := database.Connect()
if err != nil {
panic(err)
}
log.Fatal(app.Listen(":3000"))
}
If you are familiar with Express then you will find gofiber syntax is similar to Express (Node web framework).
Here we have done 3 things:-
Initialized our router.
Connected with MongoDB database.
And started our server to listen on port 3000
Let`s go through each step one by one
database/database.go
package database
import "go.mongodb.org/mongo-driver/mongo"
const Users = "users"
type MongoInstance struct {
Client *mongo.Client
Db *mongo.Database
}
Users cons store collection name so that we can reuse this variable where we required users
collection for query users data.
We have also created one struct to store MongoDB client and database pointers.
database/connection.go
package database
import (
"auth/config"
"context"
"log"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var Mg MongoInstance
func Connect() error {
dbName := config.Config("DATABASE_NAME")
uri := config.Config("DATABASE_URI") + dbName
client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI(uri))
if err != nil {
return err
}
log.Printf("Connected with databse %s", dbName)
db := client.Database(dbName)
Mg = MongoInstance{
Client: client,
Db: db,
}
return nil
}
We have defined package level variable Mg
and store database info here so that we can use this variable to query our database.
DATABASE_NAME
and DATABASE_URI
is an environment variable whose value is loaded from the .env
file.
The context
package is commonly used in Go to pass context information between different function calls in a chain or across concurrent goroutines. This package provides the ability to propagate cancellation signals and deadlines through the call stack.
The context.TODO()
function in Go's context
package is used to create a new, empty context when you don't have a more specific context available or when you are not sure which context to use.
config/config.go
package config
import (
"fmt"
"os"
"github.com/joho/godotenv"
)
// Config func to get env value
func Config(key string) string {
// load .env file
err := godotenv.Load(".env")
if err != nil {
fmt.Print("Error loading .env file")
}
return os.Getenv(key)
}
Here we have used the github.com/joho/godotenv
package to load the env variables from the .env
file.
router/router.go
package router
import (
"auth/handler"
"auth/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/monitor"
)
func New() *fiber.App {
app := fiber.New()
api := app.Group("/api")
auth := api.Group("/auth")
auth.Post("/register", handler.Register)
auth.Post("/login", handler.Login)
auth.Post("/verify_otp", handler.VerifyOTP)
auth.Post("/resend_otp", handler.ResendOTP)
auth.Get("/me", middleware.Protected(), handler.GetCurrentUser)
return app
}
We have initialized fiber and added Groups for api
and auth
so that we do need to type each path again and again. It also provides a better way to apply middleware to a group of routes instead of applying the same middleware to each route one by one.
model/user.go
package model
import "go.mongodb.org/mongo-driver/bson/primitive"
type User struct {
ID primitive.ObjectID `json:"id" bson:"_id"`
Name string `json:"name"`
Phone string `json:"phone"`
Otp string `json:"otp,omitempty"`
}
We have created a User model to store user data consistently in MongoDB database.
schema/response.go
package schema
type ResponseHTTP struct {
Success bool `json:"success"`
Data any `json:"data"`
Message string `json:"message"`
}
We will use this schema to send HTTP responses in a consistent way.
schema/auth.go
package schema
type RegisterBody struct {
Name string `json:"name"`
Phone string `json:"phone"`
}
type LoginSchema struct {
Phone string `json:"phone"`
}
type VerifyOTPSchema struct {
Phone string `json:"phone"`
Otp string `json:"otp"`
}
We will use this schema to parse the incoming request body data.
handler/auth.go
package handler
import (
"auth/model"
"auth/schema"
"auth/util"
"github.com/gofiber/fiber/v2"
)
We have added the required package imports later we will add many utility functions in util
.
// ...
// ...
func Register(c *fiber.Ctx) error {
// request body data
body := new(schema.RegisterBody)
if err := c.BodyParser(body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
// validate duplicate mobile number
user, err := util.FindUserByPhone(body.Phone)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
if user != nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: "Phone number already in use",
})
}
// create new user
id, err := util.InsertUser(body)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
Success: true,
Data: fiber.Map{
"id": id,
},
Message: "Account registered successfully",
})
}
Next in auth handler, we have added Register
function.
First, it will parse request body data and store it in the
body
.Next, it will check phone number duplication.
If everything will fine then it will create a new user in the database.
func Login(c *fiber.Ctx) error {
// request body data
body := new(schema.LoginSchema)
if err := c.BodyParser(body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
// find phone in database
user, err := util.FindUserByPhone(body.Phone)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
if user == nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: "Phone number not exists",
})
}
otp := util.GenerateRandomNumber()
// save otp in database
util.UpdateUser(user.ID, map[string]any{
"otp": otp,
})
// send otp to user phone
err = util.SendOTP(user.Phone, otp)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
Success: true,
Data: nil,
Message: "Otp sent to registered mobile number",
})
}
Here we have added a login handler it will send OTP to a mobile number if the specific user is already registered.
First, it will parse request body data and store it in the
body
.Next, it will verify the user from the database using the mobile number.
Generate OTP and update
otp
field ofusers
collection in the database.And send generated OTP to the user's mobile number.
func VerifyOTP(c *fiber.Ctx) error {
// request body data
body := new(schema.VerifyOTPSchema)
if err := c.BodyParser(body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
// find phone in database
user, err := util.FindUserByPhone(body.Phone)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
if user == nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: "Phone number not exists",
})
}
if user.Otp != body.Otp {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: "Incorrect Otp",
})
}
// generate jwt token
token, err := util.GenerateJWT(user.ID.Hex())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
// remove old otp from db
util.UpdateUser(user.ID, map[string]any{
"otp": "",
})
return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
Success: true,
Data: fiber.Map{
"token": "Bearer " + token,
},
Message: "Account login successfully",
})
}
This handler will verify the user's OTP and return the JWT Bearer token in response if OTP will be correct.
First, it will parse request body data and store it in the
body
.Verify provided phone number.
Verify provided OTP.
Create a JWT token with
userId
as a payload.Remove old OTP from the user collection.
func ResendOTP(c *fiber.Ctx) error {
// request body data
body := new(schema.VerifyOTPSchema)
if err := c.Status(fiber.StatusBadRequest).BodyParser(body); err != nil {
return c.JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
// find phone in database
user, err := util.FindUserByPhone(body.Phone)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
if user == nil {
return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: "Phone number not exists",
})
}
otp := util.GenerateRandomNumber()
// save otp in database
util.UpdateUser(user.ID, map[string]any{
"otp": otp,
})
// send otp to user phone
err = util.SendOTP(user.Phone, otp)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
Success: false,
Data: nil,
Message: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
Success: true,
Data: nil,
Message: "Sent otp to registered mobile number",
})
}
This handler will handle resend of OTP to the specific mobile number.
func GetCurrentUser(c *fiber.Ctx) error {
user := c.Locals("user").(*model.User)
user.Otp = ""
return c.Status(fiber.StatusOK).JSON(schema.ResponseHTTP{
Success: true,
Data: user,
Message: "Get current user",
})
}
This handler will return the currently logged-in user. We have added user
value in locals from auth middleware. We removed the OTP value so that the user can't get otp in the response.
util/user.go
package util
import (
"auth/config"
"auth/database"
"auth/model"
"auth/schema"
"context"
"math/rand"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func FindUserByPhone(phone string) (*model.User, error) {
// Create a context and a collection instance
ctx := context.TODO()
collection := database.Mg.Db.Collection(database.Users)
// Create a filter to find the user by phone number
filter := bson.M{"phone": phone}
// Create a variable to store the result
var result model.User
// Find the user with the given phone number
err := collection.FindOne(ctx, filter).Decode(&result)
if err != nil {
if err == mongo.ErrNoDocuments {
// If the error is ErrNoDocuments, it means no user was found
return nil, nil
}
// Handle other potential errors
return nil, err
}
return &result, nil
}
func InsertUser(user *schema.RegisterBody) (any, error) {
// Create a context and a collection instance
ctx := context.TODO()
collection := database.Mg.Db.Collection(database.Users)
// Insert the user into the collection
result, err := collection.InsertOne(ctx, user)
return result.InsertedID, err
}
func UpdateUser(userID primitive.ObjectID, updatedFields map[string]any) error {
// Create a context and a collection instance
ctx := context.TODO()
collection := database.Mg.Db.Collection(database.Users)
// Create a filter to find the user by ID
filter := bson.M{"_id": userID}
// Create an update with the provided fields
update := bson.M{"$set": updatedFields}
// Update the user document in the collection
_, err := collection.UpdateOne(ctx, filter, update)
return err
}
func FindUserById(userId string) (*model.User, error) {
// Create a context and a collection instance
id, err := primitive.ObjectIDFromHex(userId)
if err != nil {
return nil, err
}
ctx := context.TODO()
collection := database.Mg.Db.Collection(database.Users)
// Create a filter to find the user by phone number
filter := bson.M{"_id": id}
// Create a variable to store the result
var result model.User
// Find the user with the given phone number
err = collection.FindOne(ctx, filter).Decode(&result)
if err != nil {
if err == mongo.ErrNoDocuments {
// If the error is ErrNoDocuments, it means no user was found
return nil, nil
}
// Handle other potential errors
return nil, err
}
return &result, nil
}
func GenerateRandomNumber() string {
// Generate a random number between 1000 and 9999 (inclusive)
num := rand.Intn(9000) + 1000
return strconv.Itoa(num)
}
func GenerateJWT(id string) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["userId"] = id
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
return token.SignedString([]byte(config.Config("SECRET")))
}
Here we have added all utility helper function to query our database and auth-related helper functions.
util/twilio.go
package util
import (
"auth/config"
"fmt"
"log"
"github.com/twilio/twilio-go"
openapi "github.com/twilio/twilio-go/rest/api/v2010"
)
func SendOTP(to string, otp string) error {
accountSid := config.Config("TWILIO_ACCOUNT_SID")
authToken := config.Config("TWILIO_AUTH_TOKEN")
client := twilio.NewRestClientWithParams(twilio.ClientParams{
Username: accountSid,
Password: authToken,
})
params := &openapi.CreateMessageParams{}
params.SetTo(to)
params.SetFrom(config.Config("TWILIO_PHONE_NUMBER"))
msg := fmt.Sprintf("Your OTP is %s", otp)
params.SetBody(msg)
_, err := client.Api.CreateMessage(params)
if err != nil {
log.Println(err.Error())
return err
}
log.Println("SMS sent successfully!")
return nil
}
Here we have used the Twilio sms service to send OTP to mobile numbers. You need to create an account in Twilio to use their services.
You can generate your virtual mobile number from the Twilio dashboard and also copy the account sid and auth token from the dashboard.
middleware/auth.go
package middleware
import (
"auth/config"
"auth/schema"
"auth/util"
jwtware "github.com/gofiber/contrib/jwt"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
// Protected protect routes
func Protected() fiber.Handler {
return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(config.Config("SECRET"))},
ErrorHandler: jwtError,
SuccessHandler: jwtSuccess,
ContextKey: "payload",
})
}
func jwtSuccess(c *fiber.Ctx) error {
payload := c.Locals("payload").(*jwt.Token)
claims := payload.Claims.(jwt.MapClaims)
userId := claims["userId"].(string)
user, err := util.FindUserById(userId)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(schema.ResponseHTTP{
Success: false,
Message: "User not exists",
Data: nil,
})
}
c.Locals("user", user)
return c.Next()
}
func jwtError(c *fiber.Ctx, err error) error {
if err.Error() == "Missing or malformed JWT" {
return c.Status(fiber.StatusBadRequest).
JSON(schema.ResponseHTTP{Success: false, Message: "Missing or malformed JWT", Data: nil})
}
return c.Status(fiber.StatusUnauthorized).
JSON(schema.ResponseHTTP{Success: false, Message: "Invalid or expired JWT", Data: nil})
}
Protected middleware will do the following thing:-
Verify and parse the JWT token and get a payload from it.
Handle error using
jwtError
handler function in case of wrong or expired JWT token.If there is no error then it will execute
jwtSuccess
.We find the user by userId.
Add user info in locals to access from any hander.
c.Next()
will execute the next handler function.
By default ContextKey
was user
but we have changed it to payload
.
You can explore complete codebase in github
Subscribe to my newsletter
Read articles from Harsh Mangalam directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Harsh Mangalam
Harsh Mangalam
Open source developer and blogger