Building e-commerce Golang JWT part 1

Building Modern application from e-commerce platforms to SaaS dashboardsβrequire one fundamental building block before anything else can work: authentication.
Before a user can add items to a cart, checkout, store addresses, save preferences, or perform any meaningful action, they must be able to:
create an account, and
log into it securely.
This first installment of the E-Commerce API in Go series focuses entirely on building that secure foundation.
In this series, I am building a complete backend inspired by the roadmap.sh e-commerce project. But instead of jumping straight into products or payments, we start with something every real backend must handle properly:
π A robust, minimal, production-ready authentication system.
That means:
Secure user registration
Password hashing using bcrypt
Login with verification against a database
Issuing JWT tokens for user sessions
Validating tokens for protected routes
Even though the end goal is a full e-commerce platform (cart, payments, orders, and more), authentication is the backbone that everything else will rely on.
π― What We Build in This Part
This article focuses on:
Setting up MySQL using Docker
Creating a Users table
Designing a clean, layered project structure
Implementing registration (with hashed passwords)
Implementing login (with JWT token generation)
Writing clean controller β service β db code
Testing everything with
curl
By the end of this post, you will have a working backend where users can sign up and log inβusing a proper, real-world authentication system suitable for production-grade projects.
π§± 1. Start MySQL Using Docker (Required)
docker run -d \
--name mysql-ecom \
-e MYSQL_ROOT_PASSWORD=rootpass \
-e MYSQL_DATABASE=e-commerce \
-e MYSQL_USER=admin \
-e MYSQL_PASSWORD=adminpass \
-p 3306:3306 \
mysql:8
What this container setup does:
Creates a MySQL server named mysql-ecom
Exposes it on localhost:3306
Creates a database named e-commerce
Creates application user:
username:
adminpassword:
adminpass
Stores data inside the container (fine for local use)
Uses MySQL 8.x official image
Now your Go backend can connect using this DSN:
admin:adminpass@tcp(localhost:3306)/e-commerce?parseTime=true
π 2. Users Table (SQL Migration)
internal/db/migrations/db.sql:
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
What this schema represents:
idβ unique identifier for usersusernameβ must be uniquepassword_hashβ stores bcrypt hash, never plain textcreated_atβ timestamp of registrationNo email yet (kept minimal for the project requirement)
No roles / permissions at this stage
Helps keep authentication simple for beginners
π 3. Project Folder Structure
e-commerce/
βββ cmd/
β βββ main.go
βββ internal/
β βββ controller/
β βββ service/
β βββ db/
β βββ routes/
β βββ middleware/
βββ model/
β βββ model.go
βββ utils/
β βββ utils.go
βββ config.env
Why this structure?
Separates web handlers (controller) from logic (service)
Keeps database queries in one place (db/)
Prevents massive βgod filesβ
Makes the code scalable for future features (cart, products, orders)
Matches common Go project patterns
π 4. Database Connection Layer
File: internal/db/db.go
var (
db *sql.DB
once sync.Once
)
How this works:
A global
db *sql.DBconnection is shared across the applicationsync.Onceensures that DB initialization happens only oncePrevents accidental creation of multiple DB pools
func Init() error {
once.Do(func() {
db, err = sql.Open("mysql", dsn)
if err != nil {
return
}
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
err = db.Ping()
})
return err
}
Important details about this initializer:
sql.Openconfigures the MySQL driverPing()ensures DB is actually reachableThe pool is tuned:
Max lifetime per connection: 3 minutes
Up to 10 open and 10 idle connections
Improves performance for an API with multiple concurrent requests
Runs only once during app startup
User Fetch Query
func GetUserByUsername(username string) (*User, error) {
var user User
err := db.QueryRow(
"SELECT id, username, password_hash FROM users WHERE username = ?", username,
).Scan(&user.ID, &user.Username, &user.PasswordHash)
return &user, err
}
What this function does:
Fetches a user by username
If no row exists β
Scanreturns an errorPopulates:
ID
username
hashed password
Used by both:
Registration (check duplicate username)
Login (validate credentials)
User Insert Query
func CreateUser(username, passwordHash string) (int64, error) {
result, err := db.Exec(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
username, passwordHash,
)
return result.LastInsertId(), err
}
Why this insert is important:
Stores hashed password instead of plain text
Returns inserted user's ID
Uses parameterized queries (prevents SQL injection)
Ensures security best practices from the beginning
π§ 5. Utils β Hashing & JWT
utils/utils.go
Password Hashing
func GenerateHash(password []byte) ([]byte, error) {
return bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
}
How this guarantees secure passwords:
bcrypt automatically generates a random salt
Default cost is secure enough for production
Returns a long cryptographic string like:
$2a$10$kLfP...Saved directly into DB
Password Comparison
func CompareHash(hashedpassword, password []byte) bool {
return bcrypt.CompareHashAndPassword(hashedpassword, password) == nil
}
How comparison works internally:
bcrypt extracts its embedded salt
Re-hashes the incoming password
Performs constant-time comparison
Returns true only on a perfect match
Creating a JWT
func GenerateJWT(username string) (string, error) {
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
secret := LoadSecret()
return token.SignedString(secret)
}
How JWT generation works:
JWT contains:
username
expiration timestamp
Signed using HS256 algorithm
Secret is loaded from
.env(HMAC_SECRET)Output is a string like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Clients will store this and send with every request
Validating a JWT
func ValidateJWT(tokenString string) (string, error) {
secret := LoadSecret()
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil || !token.Valid {
return "", err
}
claims := token.Claims.(jwt.MapClaims)
return claims["username"].(string), nil
}
How validation works:
Decodes and verifies the signature
Ensures the algorithm matches
Checks expiry time
Extracts username field from claims
Rejects expired or tampered JWTs
π§ 6. Service Layer (auth.go)
internal/service/auth.go
Registration Logic

func RegisterUser(username, password string) error {
existing, _ := db.GetUserByUsername(username)
if existing != nil {
return errors.New("user already exists")
}
hash, err := utils.GenerateHash([]byte(password))
if err != nil {
return err
}
_, err = db.CreateUser(username, string(hash))
return err
}
What happens here:
The system checks if username already exists
Password is converted to a bcrypt hash
Hash replaces prior plain text password
Stored in DB
If user already exists β error returned
No tokens involved at this stage
Login Logic

func AuthenticateUser(username, password string) (bool, error) {
user, err := db.GetUserByUsername(username)
if err != nil {
return false, errors.New("user not found")
}
if !utils.CompareHash([]byte(user.PasswordHash), []byte(password)) {
return false, errors.New("invalid password")
}
return true, nil
}
Detailed flow:
Fetch username from DB
If user not found β return error
Compare stored bcrypt hash with given plain password
If match β authentication successful
If mismatch β invalid password
No JWT generated here; just validation
π 7. Controller Layer (HTTP Endpoints)
internal/controller/controller.go
Registration Endpoint
func RegisterHandler(c *gin.Context) {
var users model.User
if err := c.ShouldBindJSON(&users); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bad request format"})
return
}
err := service.RegisterUser(users.Username, users.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User registered successfully"})
}
What this handler does:
Reads the incoming JSON body into
model.UserValidates input format automatically via
ShouldBindJSONCalls service layer to handle the actual registration logic
Responds with success message on successful registration
Converts Go errors into HTTP error responses
Keeps controller lightweight and clean
Login Endpoint
func LoginHandler(c *gin.Context) {
var users model.User
if err := c.ShouldBindJSON(&users); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bad request format"})
return
}
ok, err := service.AuthenticateUser(users.Username, users.Password)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
token, err := utils.GenerateJWT(users.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
Detailed behavior:
Binds incoming JSON
Calls service layer to verify credentials
If authentication fails β 401 Unauthorized
If authentication succeeds:
JWT token is generated
Token includes expiration + username
Returned to user in JSON response
Token can now be used to access protected APIs in future parts
π£ 8. Route Setup
internal/routes/route.go
func SetupRoutes(r *gin.Engine) {
r.POST("/signup", controller.RegisterHandler)
r.POST("/login", controller.LoginHandler)
}
How routes are organized:
/signuphandles registration/loginhandles authenticationOnly POST methods accepted
These routes will later be grouped with protected routes like
/cart,/profile, etc.
π 9. Start the Go Server
Inside project root:
go run cmd/main.go
Backend will start on:
http://localhost:8080
π§ͺ 10. Testing Using curl
Test Registration
curl -X POST http://localhost:8080/signup \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"mypassword"}'
Response:
{"message":"User registered successfully"}
Test Login
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"mypassword"}'
Response:
{"token":"<jwt-token-here>"}
Copy this tokenβfuture endpoints will require it in:
Authorization: Bearer <token>
π 11. Authentication Flow Summary
Registration Flow
Client β /signup
controller β service β db
hash password β store to MySQL
return success
Login Flow
Client β /login
controller β service β db
verify password β generate JWT
return token
Token Usage
Client sends token β Authorization header
JWT middleware checks token
Controller gets authenticated username
π§© Final Thoughts
In this first part of the series, we built:
A production-ready JWT authentication system
Secure bcrypt password storage
MySQL-backed user registration
A layered architecture that scales
Clean controller/service/db separation


