This tutorial will guide you through protecting your Gin API endpoints. We will implement a bearer-token based authentication middleware and then add specific authorization rules to control endpoints what data users can access and modify.
We’ll protect our banking APIs so users can only access their own resources and perform authorized actions based on token-based authentication.
Table of Contents
1. The Problem: Unprotected APIs
Currently our code from beganing to till now, our APIs are open. Anyone can list all bank accounts, even if they don’t own them. Our goal is to secure these endpoints.
- Authentication: We’ll require users to provide a valid access token in an
Authorizationheader. - Authorization: Once authenticated, we’ll enforce rules. For example, a user can only list their own accounts and can only transfer money from an account they own.
2. Understanding Middleware in Gin
A middleware in Gin is a function that sits between the client’s request and the final API handler. It can process the request before it reaches the handler.
In Gin, a client request passes through middlewares (like logging or authentication) before reaching the handler. Middleware works like a handler with gin.Context, but it can stop the request and send a response without calling the real handler.

How it works:
- A client sends a request.
- The request passes through one or more middlewares (e.g., a logger, then our auth middleware).
- In the middleware, we can:
- Abort the request: If the user is not authenticated (e.g., invalid token), we can stop the request and send an error response (like
401 Unauthorized). This is done usingctx.AbortWithStatusJSON(). - Forward the request: If the user is authenticated, we can pass information (like the user’s identity from the token) to the next function in the chain using the context (
ctx.Set()) and then callctx.Next()to continue to the main handler.
- Abort the request: If the user is not authenticated (e.g., invalid token), we can stop the request and send an error response (like
Middleware Characteristics
- Intercept requests before reaching handlers.
- Can abort requests (e.g., invalid token).
- Or pass requests along with
ctx.Next().
Auth Middleware Responsibilities:
✅ Extract Authorization header.
✅ Validate token format and type (Bearer).
✅ Verify JWT token.
✅ Save payload in gin.Context.
✅ Call ctx.Next() if valid, or return 401 Unauthorized.
3. Implementing the Authentication Middleware
Let’s create the middleware to check for a valid access token.
Step 3.1: Create api/middleware.go
Create a new file api/middleware.go and add the following code. This is a higher-order function that returns our actual middleware handler.
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/your-name/simplebank/token"
)
const (
authorizationHeaderKey = "authorization"
authorizationTypeBearer = "bearer"
authorizationPayloadKey = "authorization_payload"
)
// authMiddleware creates a gin middleware for authorization
func authMiddleware(tokenMaker token.Maker) gin.HandlerFunc {
return func(ctx *gin.Context) {
authorizationHeader := ctx.GetHeader(authorizationHeaderKey)
if len(authorizationHeader) == 0 {
err := errors.New("authorization header is not provided")
ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err))
return
}
fields := strings.Fields(authorizationHeader)
if len(fields) < 2 {
err := errors.New("invalid authorization header format")
ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err))
return
}
//Split the authorization header
authorizationType := strings.ToLower(fields[0])
if authorizationType != authorizationTypeBearer {
err := fmt.Errorf("unsupported authorization type %s", authorizationType)
ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err))
return
}
accessToken := fields[1]
payload, err := tokenMaker.VerifyToken(accessToken)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err))
return
}
ctx.Set(authorizationPayloadKey, payload)
ctx.Next()
}
}
For protected api we have, eg Postman Headers with Bearer prefix, followed by a space, and the access token. The purpose of this Bearer prefix is to let the server know the type of authorization.
Because in reality, the server might support multiple types of authorization schemes,
Key Implementation Details
Header Validation:
- Checks for
Authorizationheader presence - Validates
Bearer <token>format - Converts type to lowercase for comparison
Token Verification:
- Uses token maker interface for verification
- Handles expired and invalid tokens
- Returns appropriate HTTP status codes
Context Storage:
- Stores payload in Gin context for handlers
- Uses consistent key for payload retrieval
Step 3.2: Write Unit Tests for the Middleware
Create api/middleware_test.go to test our new middleware under various conditions.
The addAuthorization function, that adds an authorization header to an HTTP request. It takes in parameters like the test object, the HTTP request object, the token maker object, the authorization type, the username, and the duration. It generates a token using the token maker object, formats the authorization header, and sets it in the request object. The function ensures that there are no errors during the token creation process.
package api
import ("fmt" "net/http" "net/http/httptest" "testing" "time"
"github.com/gin-gonic/gin"
"github.com/ngodup/simplebank/token"
"github.com/stretchr/testify/require"
)
// add authorization header to HTTP request
func addAuthorization(
t *testing.T,
request *http.Request,
tokenMaker token.Maker,
authorizationType string,
username string,
duration time.Duration,
) {
token, err := tokenMaker.CreateToken(username, duration)
require.NoError(t, err)
authorizationHeader := fmt.Sprintf("%s %s", authorizationType, token)
request.Header.Set(authorizationHeaderKey, authorizationHeader)
}
// TestAuthMiddleware tests the authentication middleware. The test will cover the following scenarios:
// 1. Valid token
// 2. Missing token
// 3. Invalid token
// 4. Expired token
func TestAuthMiddleware(t *testing.T) {
// Step 1: List all the test cases
testCases := []struct {
name string
setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) //Setup authorization header to HTTP request
checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) //Check response code
}{
//Add test cases
{
name: "OK",
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "user", time.Minute)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
//check status code is 200
},
},
{
name: "NoAuthorization",
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
//check status code is 401
},
},
{
name: "UnsupportedAuthorization",
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, "unsupported", "user", time.Minute)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
name: "InvalidAuthorizationFormat",
//No authorization prefix
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, "", "user", time.Minute)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
name: "ExpiredToken",
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "user", -time.Minute)
//add expired token -1 minute
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
}
for i := range testCases {
tc := testCases[i]
//t is subtest
t.Run(tc.name, func(t *testing.T) {
// Step 3: Create test server
server := newTestServer(t, nil)
//add simple API route & handler for testing middleware
authPath := "/auth"
server.router.GET(
authPath,
authMiddleware(server.tokenMaker),
func(ctx *gin.Context) {
//handler function
ctx.JSON(http.StatusOK, gin.H{})
})
//Send request this api route and create test recorder
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, authPath, nil)
require.NoError(t, err)
//Add authorizaton header to the request
tc.setupAuth(t, request, server.tokenMaker)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(t, recorder)
// checkResponse to verify the result
})
}
}
We can run test on individual function first and then run full test packages, we can see that all test are passed.
Test Coverage
Happy Path: Valid bearer token authentication
Error Cases: Missing header, invalid format, unsupported type, expired token
Helper Function: Reusable authorization header setup
4. Applying the Middleware to API Routes
Now that the middleware is created and tested, let’s apply it to the routes that need protection.
Protect all routes except those that need to be public (like createUser and loginUser). We use Gin’s Router Groups for this.
Route Grouping Benefits:
Clear Separation: Public vs protected endpoints
Middleware Sharing: All auth routes use same middleware
Maintainability: Easy to add/remove protected routes
In api/server.go, modify setupRouter to create a route group.
// In api/server.go
//Other codes ...
func (server *Server) setupRouter() {
router := gin.Default()
// add routes to server
router.POST("/users", server.createUser)
router.POST("/users/login", server.loginUser)
//Hiher order function
authRoutes := router.Group("/").Use(authMiddleware(server.tokenMaker))
authRoutes.POST("/accounts", server.createAccount)
authRoutes.GET("/accounts/:id", server.getAccount)
authRoutes.GET("/accounts", server.listAccount)
authRoutes.POST("/transfers", server.createTransfer)
server.router = router
}
Since we changed the routes, all API requests except two now must go through the auth middleware first. Many existing API unit tests will fail because of this change.
For example, in api/account_test.go, running the test TestGetAccountAPI will fail because the API now returns 401 instead of 200.
To fix this and make the API tests pass, we need to add authorization headers to the requests, like we did in the middleware unit tests.
However, the auth middleware only handles authentication – it just requires clients to provide an access token to pass requests to the handler. It doesn’t check if the token owner has permission to perform the action. We’re still missing authorization logic. Authorization rules differ for each API, so we typically implement authorization logic inside each handler. For example, in the create account API, users shouldn’t be able to create accounts for other users. The rule is: users can only create accounts for themselves.
API Authorization Rules
- Create Account API: A logged-in user can only create an account for him/herself
- Get Account API: A logged-in user can only get accounts that he/she owns
- List Accounts API: A logged-in user can only list accounts that belong to him/her
- Transfer Money API: A logged-in user can only send money from his/her own account
5. Implementing Authorization Logic
Authentication confirms who the user is. Authorization decides what they are allowed to do. We’ll implement this logic inside each handler. We will need to fixed certains thing, as now user name can be get from authorization payload from context.
Step 5.1: Update CreateAccount
A user should only be able to create an account for themselves.
In api/account.go, modify createAccount:
// In api/account.go, inside createAccount handler
type createAccountRequest struct {
Currency string `json:"currency" binding:"required,currency"`
// Remove `Owner` from `createAccountRequest` struct. The owner should be the username of the logged in user, stored in the authorization payload.
}
func (server *Server) createAccount(ctx *gin.Context) {
var req createAccountRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
// The owner will be the logged-in user & get the authorization payload from the context
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
arg := db.CreateAccountParams{
Owner: authPayload.Username, // Set owner from the token
Currency: req.Currency,
Balance: 0,
}
// ... rest of the function
Step 5.2: Update GetAccount
A user should only be able to get details of an account they own.In api/account.go, modify getAccount, to get authorization payload from context and check if account.Owner is same as authPayload.Username
// In api/account.go, inside getAccount handler
func (server *Server) getAccount(ctx *gin.Context) {
// ... after getting the account from the DB ...
account, err := server.store.GetAccount(ctx, req.ID)
if err != nil {
// ... error handling ...
return
}
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
if account.Owner != authPayload.Username {
err := errors.New("account doesn't belong to the authenticated user")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, account)
Step 5.3: Update ListAccounts
A user should only be able to list their own accounts but our SQL query doesn’t support filtering accounts by owner yet, So we have to update it first. In the ListAccounts query of the account.sql file,
And change the parameter index of LIMIT and OFFSET to $2 and $3 respectively.
- Update SQL Query: In
db/query/account.sql, modify theListAccountsquery to filter by owner.-- name: ListAccounts :manySELECT * FROM accounts WHERE owner = $1 ORDER BY id LIMIT $2 OFFSET $3; - Regenerate Code: Run
make sqlcto update the Go database code. - Also run make mock to regenerate the mock store for API unit tests. We can check db/sqlc/account.sql.go where new Owner field has been added to the ListAccountsParam struct. This change will break our db test of the ListAccounts function.
Test Borke for existing test TestListAccounts
We modified the ListAccounts query in db/query/account.sql by adding WHERE owner = $1 — <- NEW FILTER, we did this in previous section by running make sqlc: This regenerated the Go code. The ListAccountsParams struct now has a new Owner field, and the function signature changed.
The Test Broke: The existing test TestListAccounts is now broken because:
- It doesn’t provide the new required
Ownerparameter. - The old logic of creating 10 random accounts for 10 different users and then expecting to get 5 back no longer makes sense. The new query will only return accounts for one specific owner.
The Solution: Fixing the Test
The goal of the test is to verify that ListAccounts works correctly with its new filter. The fix involves these steps:
1. Create a Fixed Owner for the Test:
Instead of having 10 different owners, create one single user and create multiple accounts for that same user. This way, when we filter by that user, we should get multiple results.
2. Update the Call to ListAccounts:
Provide the new Owner field in the ListAccountsParams.
3. Update the Assertions:
Since we’re now filtering for one owner, we check that:
- The returned list is not empty.
- Every single account in the returned list belongs to the expected owner.
Simplified Steps to Fix db/sqlc/account_test.go Here is a clearer, step-by-step guide to rewrite the TestListAccounts function:
// existing code
func TestListAccounts(t *testing.T) {
var lastAccount Account
// Create 10 random accounts, each for a different user.
for i := 0; i < 10; i++ {
lastAccount = createRandomAccount(t)
}
// Build query params: filter accounts by the owner of the last created account
arg := ListAccountsParams{
Owner: lastAccount.Owner,
Limit: 5,
Offset: 0,
}
// Run the ListAccounts query with the params
accounts, err := testQueries.ListAccounts(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, accounts)
// Verify that all returned accounts are not empty
// and all have the same owner as the lastAccount
for _, account := range accounts {
require.NotEmpty(t, account)
require.Equal(t, lastAccount.Owner, account.Owner)
}
}
Run this test, it pass the test
Add Authorization rules on ListAccount
This is countinue of the step 5, We’ve added authorization rules to all account management APIs, except the most important one: the transfer money API. Now, in api/account.go, we’ll add authorization logic to the list accounts handler, similar to the create and get account handlers. We simply get the authorization payload from the context, then set the Owner field of ListAccountsParams to authPayload.Username. That’s it.
// In api/account.go, inside listAccounts handler
func (server *Server) listAccount(ctx *gin.Context) {
var req listAccountRequest
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
// Get authenticated user from context
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
//Check authorization logic to list account
arg := db.ListAccountsParams{
Owner: authPayload.Username, // Filter by authenticated user
Limit: req.PageSize,
Offset: (req.PageID - 1) * req.PageSize,
}
accounts, err := server.store.ListAccounts(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, accounts)
}
Step 5.4: Update CreateTransfer
Now we also need to add authorization logic to Transfer api handler. A user can only send money from an account they own. The last API that needs authorization is the transfer money API, and it’s the most important.
Rule: A user can only transfer money from their own account, not from someone else’s.
Steps to implement:
- In the
validAccountfunction:- Return both the
db.Accountobject and thevalidflag. - Update all return statements accordingly.
- Return both the
- In the
createTransferhandler:- Call
validAccountfor the from-account and save the result infromAccount, valid. - If not valid, return an error.
- Do the same for the to-account, but since we don’t need ownership there, just use
_ , valid.
- Call
- Add authorization logic:
- Get the
authPayload(authenticated user info) from the context. - Check if
fromAccount.Owner == authPayload.Username. - If they don’t match, return a 401 Unauthorized with an error message like:
"from account doesn’t belong to the authenticated user".
- Get the
- Small fix: remove the
:=(colon) when reusing thevalidvariable.
Let modify the api/account.go
func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) (db.Account, bool) {
account, err := server.store.GetAccount(ctx, accountID)
if err != nil {
if err == sql.ErrNoRows {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return account, false
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return account, false
}
if account.Currency != currency {
err := fmt.Errorf("account [%d] currency mismatch: %s vs %s", accountID, account.Currency, currency)
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return account, false
}
return account, true
}
func (server *Server) createTransfer(ctx *gin.Context) {
var req transferRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
fromAccount, valid := server.validAccount(ctx, req.FromAccountID, req.Currency)
if !valid {
return
}
// Authorization from ctx é check user is owner Account
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
if fromAccount.Owner != authPayload.Username {
err := errors.New("from account doesn't belong to the authenticated user")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}
_, valid = server.validAccount(ctx, req.ToAccountID, req.Currency)
if !valid {
return
}
arg := db.TransferTxParams{
FromAccountID: req.FromAccountID,
ToAccountID: req.ToAccountID,
Amount: req.Amount,
}
result, err := server.store.TransferTx(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, result)
}
6. Updating API Unit Tests
With the new middleware, our old API tests will fail. We update unit test to addapt with new authenitcation middleware and authorization logics. Also need to add a valid Authorization header to the requests in our tests.
Here’s how to update the TestGetAccountAPI as an example: At the moment, we are generating a random owner inside the random account function. But to make it easier to test the authentication and authorization logic, we can now used a owner string as input. And use it as the account owner instead of generating a random value as before.
Now in the test get account API function, Then we pass that user.Username into the function to generate a random account.
// In api/account_test.go
func randomAccount(owner string) db.Account {
return db.Account{
ID: int64(util.RandomInt(1, 1000)),
Owner: owner, //from util.RandomeOwner
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
}
func TestGetAccountAPI(t *testing.T) {
user, _ := randomUser(t)
account := randomAccount(user.Username)
testCases := []struct {
// ...
setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker)
}{
{
name: "OK",
// ...
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute)
},
// ...
},
// Add new test cases for failure scenarios
{
name: "UnauthorizedUser",
// ...
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "unauthorized_user", time.Minute)
},
// checkResponse should expect http.StatusUnauthorized
},
{
name: "NoAuthorization",
// ...
setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) {
// No header is added
},
// checkResponse should expect http.StatusUnauthorized
},
}
for i := range testCases {
// ... inside the t.Run block
tc.setupAuth(t, request, server.tokenMaker) // Call this before serving the request
server.router.ServeHTTP(recorder, request)
// ...
}
}
Apply this same pattern to the tests for CreateAccount, ListAccounts, and CreateTransfer. By copying user, _ := randomUser(t)
account := randomAccount(user.Username) these two add apply to TestCreateAccountAPI in api/account_test.go, and TestListAccounts, and TestCreateTransfer in transfer_test.go files.
In the transfer_test.go file In this TestTransferAPI, we have 3 different accounts, So let’s create 3 different users for each of them. To create
func TestTransferAPI(t *testing.T) {
amount := int64(10)
user1, _ := randomUser(t)
user2, _ := randomUser(t)
user3, _ := randomUser(t)
account1 := randomAccount(user1.Username)
account2 := randomAccount(user2.Username)
account3 := randomAccount(user3.Username)
Once it is done, then all unit test errors are fixed.Let’s run the GetAccountAPI unit tests and we see that all test are passed and we aslo add more test cases list in the api/account_test.go is already done.
Test Authentication
1. Try Accessing Protected Endpoint Without Token:
GET http://localhost:8080/accounts
Response :
HTTP/1.1 401 Unauthorized
{
"error": "authorization header is not provided"
}
To get accounts from particular user, we need to login first to get access token. We need to pass access token to get api request authorization, eg to get accunts from particular user we need to pass the access token along with HTTP request.

7. Conclusion
We have successfully implemented a secure API layer:
- Authentication: The
authMiddlewarereliably identifies the user making the request. - Authorization: Individual handler functions enforce business rules, ensuring users can only perform actions on their own data.
This combination is crucial for building trustworthy and secure applications. Remember to thoroughly test all edge cases (unauthorized access, expired tokens, etc.) to ensure your security model is robust.