Go Authentication and JWT Tutorial

Go Authentication and JWT Tutorial

Authentication is one of those topics that looks simple at first glance and then quietly becomes the heart of your entire application. The moment your project grows beyond a toy example, you start caring about who can sign in, how sessions are protected, how tokens are issued, where they are stored, how they expire, and what happens when something goes wrong. In Go, this becomes especially interesting because the language encourages clarity, small pieces, and explicit control. That is a gift when building authentication. It gives you the chance to understand every moving part instead of hiding everything behind a framework that feels magical until it breaks at the worst possible time.

JWT, or JSON Web Token, is one of the most common ways to implement stateless authentication in modern APIs. It is loved because it is compact, portable, easy to send in HTTP headers, and simple to verify without hitting the database on every request. It is also misunderstood very often. Many developers treat JWT like a safe box for sensitive data, or assume it replaces all security concerns by itself. It does not. JWT is just a signed token format. The real security comes from how you create it, how you validate it, how you rotate secrets, how you store it on the client, and how you design the full authentication flow around it.

In this tutorial, we will build a practical Go authentication system with JWT from the ground up. The focus is not only on “make it work,” but on “make it understandable.” We will go step by step through password hashing, login, token creation, protected routes, middleware, claims, expiration, refresh strategies, and best practices. The examples will use standard Go patterns and a popular JWT library so that the code remains realistic and usable in a real project. By the end, you will have a clear mental model of how JWT authentication works in Go and how to apply it safely in your own API.

What Authentication Means in a Go API

Authentication answers a very specific question: who is this user? It is different from authorization, which answers what this user is allowed to do. In many applications, these two concepts are mixed together in conversations, but in code they should be separated as much as possible. Authentication happens first. Once the user is known, authorization decides whether they may access a resource, change a record, or enter an admin panel.

In a traditional session-based web app, the server remembers the user after login by storing a session ID in a cookie and keeping the session data on the server. In a JWT-based system, the server usually does not store the session state. Instead, it signs a token and sends it back to the client. The client includes that token in later requests, and the server verifies the signature and reads the claims. This approach is called stateless because the server does not need to keep a full record of each active login session.

That statelessness is convenient, especially for APIs, mobile apps, and distributed systems. It reduces server-side storage and makes scaling easier. But it also means you must think carefully about token expiration, revocation, and storage on the client side. Stateless does not mean careless. It means the responsibility moves into design decisions.

Why Go Is a Strong Choice for Authentication

Go is a strong choice for authentication services because it is fast, easy to deploy, and well suited for concurrent network applications. An authentication service often needs to handle many requests, verify credentials, sign tokens, query databases, and respond quickly. Go handles this naturally with lightweight goroutines and efficient standard library tools.

Another reason Go works well is that it does not hide the details. You can build authentication with net/http, use a dedicated router if you want, choose your password hashing algorithm explicitly, and manage token validation in middleware that you fully control. This creates code that is easier to audit. Security code benefits from being boring, readable, and predictable. Go tends to encourage exactly that.

There is also a practical benefit: many backend teams use Go for APIs that sit between front-end clients and databases or services. JWT authentication fits that ecosystem neatly. You can issue tokens from one Go service and verify them in another. You can place auth logic in a shared package. You can keep the code small while still being robust.

JWT in Plain Language

A JWT is a string made of three parts:

  1. Header

  2. Payload

  3. Signature

These parts are Base64URL encoded and separated by dots. The header usually describes the algorithm, such as HS256 or RS256. The payload contains claims, which are small pieces of information about the user or token. The signature proves that the token was created by someone who knows the secret key or private key.

A JWT is not encrypted by default. That matters a lot. Anyone who gets the token can decode the header and payload and read the claims. They cannot change them without invalidating the signature, but they can read them. For that reason, you should never put sensitive data such as passwords, secret answers, credit card numbers, or private personal information inside the JWT payload.

Here is a simple example of what a token might carry:

{
  "sub": "12345",
  "email": "user@example.com",
  "role": "user",
  "exp": 1719999999,
  "iat": 1719990000
}

This is enough for many APIs. The sub claim often stores the user ID. exp is the expiration time, iat is the issued-at time, and additional custom claims like role can help with authorization checks.

How the Authentication Flow Usually Works

A common JWT authentication flow looks like this:

The user sends a username and password to a login endpoint. The server checks whether the credentials are valid. If the password matches the stored hash, the server creates a signed JWT and returns it to the client. The client stores the token in a secure way and sends it with future requests, usually in the Authorization header as a Bearer token. The server verifies the token on protected routes. If the token is valid and not expired, access is granted.

That sounds simple, but each step contains important decisions.

The password should be hashed, not stored in plain text.
The token should have a reasonable expiration.
The signing secret should be strong and protected.
The client should store the token safely.
The middleware should reject malformed or expired tokens quickly.
And the API should return useful error messages without revealing too much.

Authentication is not just about logging in. It is about building trust between the client and server repeatedly, one request at a time.

Project Structure

Before writing code, it helps to think about the structure of the project. A simple Go project for JWT auth might look like this:

go-auth-jwt/
├── main.go
├── go.mod
├── handlers/
│   ├── auth.go
│   └── user.go
├── middleware/
│   └── auth.go
├── models/
│   └── user.go
├── utils/
│   ├── jwt.go
│   └── password.go
└── config/
    └── config.go

You do not need to split everything into many folders for a small project, but this structure helps keep concerns separate. The JWT logic stays in one place. Password hashing stays in one place. Route handlers stay readable. Middleware stays reusable.

For this tutorial, we will keep the code in fewer files so that the ideas are easier to follow. In a real project, you can split it up once you are comfortable.

Installing Dependencies

We will use the popular JWT library from github.com/golang-jwt/jwt/v5. It is widely used and makes token creation and parsing easy.

Initialize your project:

go mod init go-auth-jwt
go get github.com/golang-jwt/jwt/v5

If you plan to use bcrypt for password hashing, you will also need:

go get golang.org/x/crypto/bcrypt

That is enough for the core tutorial.

Building a Simple User Model

In real life, users come from a database. For tutorial purposes, we will start with an in-memory user structure and then talk about how to move it to a database.

package main

type User struct {
	ID       string
	Email    string
	Password string
	Role     string
}

The Password field should store the hashed password, not the raw password. That is one of the most important security habits in backend development. Even in a demo, it is better to keep the habit correct from the beginning.

Password Hashing with bcrypt

Never store plain-text passwords. That rule is non-negotiable in any serious application. If your database leaks and passwords are stored in plain text, the damage can be enormous. The correct approach is to hash passwords using a password hashing algorithm like bcrypt.

Bcrypt is designed for password storage. It is intentionally slow, which makes brute-force attacks harder. It also salts the hash automatically. That means two users with the same password will not have identical stored hashes.

Here is a helper function to hash a password:

package main

import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(bytes), nil
}

And here is how to compare a raw password with a stored hash:

func CheckPasswordHash(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

A registration endpoint might call HashPassword before saving the user. A login endpoint might call CheckPasswordHash to verify the password entered by the user. That is the right place to use bcrypt: on the server side, before authentication succeeds.

Creating JWT Tokens

Now let us create the token itself. A JWT usually contains standard claims and custom claims. Standard claims often include sub, exp, iat, nbf, and iss. Custom claims can include whatever your application needs, such as role, username, or tenant ID.

Here is a simple JWT helper:

package main

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("super-secret-key-change-this-in-production")

type Claims struct {
	UserID string `json:"user_id"`
	Email  string `json:"email"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

func GenerateToken(userID, email, role string) (string, error) {
	expirationTime := time.Now().Add(15 * time.Minute)

	claims := &Claims{
		UserID: userID,
		Email:  email,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "go-auth-app",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

This function generates a token that expires after 15 minutes. A short expiration is a good security habit. If the token leaks, the damage window is smaller. You can always issue a refresh token or let the user log in again when necessary.

Understanding Claims Properly

Claims are not just random metadata. They are part of the authentication contract. Some claims are standard and recognized by JWT libraries and systems. Others are application-specific.

Standard claims include:

  • iss for issuer

  • sub for subject

  • aud for audience

  • exp for expiration time

  • nbf for not-before

  • iat for issued-at

  • jti for JWT ID

Custom claims can be anything your app needs, such as:

  • user_id

  • email

  • role

  • permissions

  • team_id

A good rule is to keep JWT payloads small. Put enough data to identify the user and make basic access decisions, but not so much that the token becomes bulky or sensitive. You usually do not want to place everything about the user inside the token. The database still has a role to play.

Building a Login Handler

Now let us create a login endpoint. For simplicity, we will simulate a user record in memory.

package main

import (
	"encoding/json"
	"net/http"
)

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	err := json.NewDecoder(r.Body).Decode(&req)
	if err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	// Example user record. In real life, fetch from database.
	storedUser := User{
		ID:       "1",
		Email:    "user@example.com",
		Password: "$2a$10$J8M1yF3xM2gXnq8JYy6kvebG2j2h7oD3vR8fYxKc4m9q5w7u1z0dS", // example hash
		Role:     "user",
	}

	if req.Email != storedUser.Email {
		http.Error(w, "invalid credentials", http.StatusUnauthorized)
		return
	}

	if !CheckPasswordHash(req.Password, storedUser.Password) {
		http.Error(w, "invalid credentials", http.StatusUnauthorized)
		return
	}

	token, err := GenerateToken(storedUser.ID, storedUser.Email, storedUser.Role)
	if err != nil {
		http.Error(w, "could not create token", http.StatusInternalServerError)
		return
	}

	response := map[string]string{
		"token": token,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

This handler receives the email and password, checks them, and returns a token if everything is valid. Notice that the error message is generic: “invalid credentials.” This is intentional. You do not want to reveal whether the email exists or whether the password was wrong. That kind of detail can help attackers enumerate users.

Protecting Routes with Middleware

A token is useful only if you can verify it on protected routes. Middleware is the cleanest place to do that. Middleware can inspect the request before it reaches the final handler, check the Authorization header, validate the token, and either continue or reject the request.

Here is an authentication middleware:

package main

import (
	"context"
	"net/http"
	"strings"

	"github.com/golang-jwt/jwt/v5"
)

type contextKey string

const userContextKey contextKey = "user"

func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if authHeader == "" {
			http.Error(w, "missing authorization header", http.StatusUnauthorized)
			return
		}

		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
		if tokenString == authHeader {
			http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
			return
		}

		claims := &Claims{}
		token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
			return jwtSecret, nil
		})

		if err != nil || !token.Valid {
			http.Error(w, "invalid or expired token", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userContextKey, claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

This middleware extracts the Bearer token, parses it, validates it using the secret, and stores the claims in the request context. The final handler can then read the claims and use them to personalize the response or enforce permissions.

Reading the Authenticated User from Context

Once the middleware has validated the token, the handler can get the claims from context.

func protectedHandler(w http.ResponseWriter, r *http.Request) {
	claims, ok := r.Context().Value(userContextKey).(*Claims)
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	response := map[string]string{
		"message": "Welcome, " + claims.Email,
		"user_id": claims.UserID,
		"role":    claims.Role,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

This is a simple and elegant pattern. The middleware authenticates once. The handler focuses on business logic. That separation keeps your code clean.

Putting the Server Together

Let us wire up the routes in main.go.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/login", loginHandler)

	protected := http.HandlerFunc(protectedHandler)
	mux.Handle("/profile", AuthMiddleware(protected))

	fmt.Println("Server running on :8080")
	http.ListenAndServe(":8080", mux)
}

Now the flow is simple:

  • POST /login returns a JWT

  • GET /profile requires Authorization: Bearer <token>

That is enough to demonstrate a complete JWT auth loop.

Testing the Login Endpoint

Try sending a login request like this:

curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"secret123"}'

If the credentials are correct, the response may look like this:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Then use the token on the protected route:

curl http://localhost:8080/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

If the token is valid, you will receive the profile data. If the token is missing, expired, altered, or signed with the wrong secret, the request fails with 401 Unauthorized.

Why the Authorization Header Matters

There are several ways to transport a JWT, but the Authorization header with the Bearer scheme is the standard for APIs. It looks like this:

Authorization: Bearer <token>

This approach is clean because it is explicit and works well across clients. Browsers, mobile apps, command-line tools, and server-to-server requests all understand it. Some applications store JWTs in cookies instead, especially when they want to leverage HTTP-only cookie protection. That can be a good choice too, depending on your architecture. But for APIs, Bearer tokens are common and easy to reason about.

The real danger is not the header itself. The real danger is where you store the token on the client. If you place JWTs in local storage in a browser, they can be more vulnerable to XSS attacks. If you place them in HTTP-only cookies, you reduce some risks but must think about CSRF protection. There is no perfect storage method that removes all trade-offs. The right choice depends on your application’s risk profile.

Token Expiration

A JWT should almost always expire. This matters because if a token gets stolen, an expiration time limits how long an attacker can use it. Short-lived access tokens are a strong pattern. A typical setup uses:

  • a short-lived access token

  • a longer-lived refresh token

When the access token expires, the client uses the refresh token to get a new one without forcing the user to log in every few minutes.

For a simple tutorial, you may only implement access tokens first. But in a production system, refresh tokens often make the experience much smoother and the security model more practical.

Refresh Tokens in a Real System

A refresh token is a special token used to obtain a new access token. It usually lasts longer than the access token, sometimes days or weeks. Unlike access tokens, refresh tokens are often stored and tracked by the server so that they can be revoked if needed.

A common strategy is:

  • access token: short expiration, used on each request

  • refresh token: long expiration, used only to request a new access token

This split is useful because access tokens are sent often and should be lightweight and short-lived, while refresh tokens are handled less frequently and can be protected more carefully.

A simple refresh flow might look like this:

func refreshHandler(w http.ResponseWriter, r *http.Request) {
	// Normally validate refresh token here
	// Then issue a new access token
	newToken, err := GenerateToken("1", "user@example.com", "user")
	if err != nil {
		http.Error(w, "could not refresh token", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{
		"token": newToken,
	})
}

In a real application, refresh tokens should be stored securely and often tracked in a database with revocation support. You do not want a stolen refresh token to become a permanent backdoor.

Secret Management

The secret key used to sign JWTs is very important. If an attacker gets it, they can create valid tokens and impersonate users. That is why the secret must not be hard-coded in your repository.

Instead, load it from environment variables or a secret manager. For example:

import "os"

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

Then set the environment variable when running the application:

export JWT_SECRET="a-very-long-random-secret-value"
go run main.go

In production, use a stronger secret management approach, such as container secrets, cloud secret stores, or deployment platform secrets. The more serious the application, the more serious your secret handling should be.

HS256 vs RS256

When working with JWT, you will often encounter algorithms such as HS256 and RS256.

HS256 uses a shared secret. The same key signs and verifies the token. This is simple and good for smaller systems or single services.

RS256 uses an asymmetric key pair. The private key signs the token, and the public key verifies it. This is more suitable when multiple services need to verify tokens without being able to sign them. It also provides better separation of duties.

For a tutorial and many internal apps, HS256 is fine if the secret is managed correctly. For larger systems, RS256 can be a better architectural fit.

The important thing is to choose one deliberately. Do not accept the default without understanding the operational implications.

Authorization with Roles

Once authentication works, authorization becomes much easier to express. Suppose the JWT contains a role claim. You can use it to restrict routes.

func adminOnlyHandler(w http.ResponseWriter, r *http.Request) {
	claims, ok := r.Context().Value(userContextKey).(*Claims)
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	if claims.Role != "admin" {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}

	w.Write([]byte("Welcome admin"))
}

This is authorization: the user is authenticated, but only admins may access this route. Many systems grow from simple role checks to more advanced permission models. The same idea still applies. The token identifies the user, and your business rules decide whether access is granted.

Common Mistakes with JWT

JWT is powerful, but many mistakes are repeated constantly.

One mistake is storing sensitive information in the payload. The payload is only encoded, not encrypted.

Another mistake is using a token that never expires. Permanent tokens are dangerous. They turn every leak into a long-term problem.

A third mistake is failing to check expiration and signature properly. A token must be both valid and unexpired.

Another common mistake is accepting any signing algorithm without checking it. Your parsing logic should expect the algorithm you actually use.

Yet another mistake is using JWT where a simple session would be better. JWT is not automatically superior. Sometimes server-side sessions are simpler and more secure for a given app.

Good engineering means choosing the right tool, not the trendiest one.

Validating the Signing Method

When parsing a JWT, you should verify that the token was signed with the expected method. Some attacks historically involved algorithm confusion. Even if modern libraries protect against many of these issues, it is still wise to check.

Here is a safer parsing example:

token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
	if token.Method != jwt.SigningMethodHS256 {
		return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
	}
	return jwtSecret, nil
})

This small check adds an extra layer of confidence. Security code benefits from explicit assumptions.

Handling Errors Gracefully

Authentication errors should be helpful but not overly revealing. A good API returns clear status codes and plain messages, but not detailed internal traces.

For example:

  • 400 Bad Request for malformed input

  • 401 Unauthorized for invalid credentials or invalid token

  • 403 Forbidden for authenticated users who lack permission

  • 500 Internal Server Error for unexpected issues

The difference between 401 and 403 matters. 401 means the user is not authenticated or the token is invalid. 403 means the user is authenticated but not allowed to perform the action.

This distinction helps both developers and clients understand what is happening.

Adding a Registration Endpoint

A complete authentication system also needs registration. Here is a simple version:

type RegisterRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req RegisterRequest
	err := json.NewDecoder(r.Body).Decode(&req)
	if err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	hashedPassword, err := HashPassword(req.Password)
	if err != nil {
		http.Error(w, "could not hash password", http.StatusInternalServerError)
		return
	}

	user := User{
		ID:       "2",
		Email:    req.Email,
		Password: hashedPassword,
		Role:     "user",
	}

	// Save user to database here
	_ = user

	w.WriteHeader(http.StatusCreated)
	w.Write([]byte("user registered"))
}

In a real application, you would validate the email, enforce password rules, check for duplicates, and persist the user in a database. The important thing here is the pattern: always hash before saving.

Using a Database Instead of Memory

An in-memory user is fine for learning, but real applications need persistence. You can use MySQL, PostgreSQL, SQLite, or any database you prefer.

The login flow with a database usually works like this:

  1. Receive email and password

  2. Query the user by email

  3. Compare the supplied password with the stored bcrypt hash

  4. Generate JWT if the password is correct

Here is a conceptual example:

func findUserByEmail(email string) (User, error) {
	// Replace with database query
	return User{}, nil
}

func databaseLoginHandler(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	err := json.NewDecoder(r.Body).Decode(&req)
	if err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	user, err := findUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "invalid credentials", http.StatusUnauthorized)
		return
	}

	if !CheckPasswordHash(req.Password, user.Password) {
		http.Error(w, "invalid credentials", http.StatusUnauthorized)
		return
	}

	token, err := GenerateToken(user.ID, user.Email, user.Role)
	if err != nil {
		http.Error(w, "could not generate token", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"token": token})
}

This is the form you will use in most real backends. The database is the source of truth for users, while the JWT becomes the portable proof of authentication.

Token Revocation and Logout

One challenge with JWT is that stateless tokens are not easy to revoke immediately. If a token is valid until 3:00 PM and the user logs out at 2:00 PM, the server cannot magically invalidate every copy of that token unless you build additional logic.

There are a few solutions:

  • Keep access tokens short-lived

  • Maintain a blacklist of revoked tokens

  • Use refresh tokens and revoke refresh tokens on logout

  • Store token IDs and check them against a session table

For many applications, short-lived access tokens plus revocable refresh tokens are enough. Logout then means deleting or invalidating the refresh token so that new access tokens cannot be issued.

Secure Storage on the Client

A JWT is only as safe as its storage location. In a browser app, local storage is simple but can be risky if your site has an XSS vulnerability. HTTP-only cookies are safer against JavaScript access, but then you need to think about CSRF protections. Mobile apps often use secure storage provided by the platform.

There is no one-size-fits-all answer. The right solution depends on your architecture. The key idea is to avoid putting tokens somewhere careless just because it is convenient. Convenience and security do not always move in the same direction.

A More Complete Example

Here is a compact example that ties everything together.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

type User struct {
	ID       string
	Email    string
	Password string
	Role     string
}

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

type Claims struct {
	UserID string `json:"user_id"`
	Email  string `json:"email"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

type contextKey string

const userContextKey contextKey = "user"

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

func HashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(bytes), nil
}

func CheckPasswordHash(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

func GenerateToken(userID, email, role string) (string, error) {
	expirationTime := time.Now().Add(15 * time.Minute)

	claims := &Claims{
		UserID: userID,
		Email:  email,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "go-auth-app",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	// Replace with database lookup
	hash, _ := HashPassword("secret123")
	user := User{
		ID:       "1",
		Email:    "user@example.com",
		Password: hash,
		Role:     "user",
	}

	if req.Email != user.Email || !CheckPasswordHash(req.Password, user.Password) {
		http.Error(w, "invalid credentials", http.StatusUnauthorized)
		return
	}

	token, err := GenerateToken(user.ID, user.Email, user.Role)
	if err != nil {
		http.Error(w, "could not generate token", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"token": token})
}

func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if authHeader == "" {
			http.Error(w, "missing authorization header", http.StatusUnauthorized)
			return
		}

		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
		if tokenString == authHeader {
			http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
			return
		}

		claims := &Claims{}
		token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
			if token.Method != jwt.SigningMethodHS256 {
				return nil, fmt.Errorf("unexpected signing method")
			}
			return jwtSecret, nil
		})

		if err != nil || !token.Valid {
			http.Error(w, "invalid or expired token", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userContextKey, claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func profileHandler(w http.ResponseWriter, r *http.Request) {
	claims, ok := r.Context().Value(userContextKey).(*Claims)
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{
		"message": "Hello " + claims.Email,
		"user_id": claims.UserID,
		"role":    claims.Role,
	})
}

func main() {
	if len(jwtSecret) == 0 {
		panic("JWT_SECRET is required")
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/login", loginHandler)
	mux.Handle("/profile", AuthMiddleware(http.HandlerFunc(profileHandler)))

	fmt.Println("Server running on :8080")
	http.ListenAndServe(":8080", mux)
}

This example is intentionally compact so you can see the whole flow at once. In a real codebase, you would extract helpers, use a database, add validation, and improve error handling. But the essential architecture would stay the same.

Practical Best Practices

A good JWT auth system is not built by code alone. It is built by habits.

Keep access tokens short-lived.
Store secrets in environment variables or secret managers.
Hash passwords using bcrypt or another strong password hashing scheme.
Never store sensitive information in the JWT payload.
Verify token signature and algorithm carefully.
Use HTTPS everywhere.
Return generic credential errors.
Separate authentication from authorization.
Plan for logout and token revocation.
Keep client storage choices deliberate, not accidental.

These practices sound small, but together they make the difference between a demo and a reliable system.

When Not to Use JWT

JWT is popular, but it is not always the best answer. If your app is a simple server-rendered website, classic server-side sessions may be easier and safer. If you need immediate revocation for every request, a session store may be simpler. If your team is not comfortable managing token lifetimes and client storage, it may be better to keep the architecture simple.

Good engineering is not about using JWT everywhere. It is about using it where it fits.

Final Thoughts

Authentication is one of the most important parts of an application because it sits at the border between public and private access. When it works well, nobody notices. When it works badly, everything feels fragile. JWT in Go gives you a practical way to build stateless authentication with speed and clarity, but the real value comes from understanding the full flow, not just copying a token snippet.

The biggest lesson is this: security is not a single function. It is a chain of choices. Password hashing, token signing, expiration, storage, middleware, authorization, secret handling, and error behavior all matter together. Go makes this chain easier to see, because the code stays simple enough to understand. That simplicity is a strength, especially in security-related systems where hidden magic can become hidden risk.

If you build the system carefully, JWT can be an elegant solution for APIs, mobile backends, microservices, and modern web applications. Start small, keep your tokens short-lived, treat secrets like secrets, and always remember that authentication is not just about proving identity once. It is about keeping that proof trustworthy every single time the client makes a request.

#Go authentication #JWT in Go #Go JWT tutorial #Go login system #Go middleware #JWT claims #bcrypt password hashing #Go API security #refresh token #protected routes in Go #Go web development #authentication tutorial #Go backend security

Subscribe to our newsletter

12k+

Subscribers

Weekly

Frequency

Free

Always