Build a JWT Authentication API in Go
Build a secure REST API in Go with bcrypt password hashing and JWTs.
Introduction
30 Second Summary
Every time you log into an app, something behind the scenes verifies your password and hands back a digital pass. That system is called authentication.
In this project, you will build a complete authentication API in Go from scratch. You'll go from a wide-open endpoint anyone can access to a locked-down API where only verified users get through.
What You'll Build
You'll curl a protected endpoint with your signed token and watch the API serve data back, proving that only authenticated users get through.
By the end of this project, you'll have:
- A user registration endpoint that stores passwords as unreadable bcrypt hashes instead of plain text.
- A login system that verifies credentials and returns a signed JWT token as proof of identity.
- Middleware that blocks unauthenticated requests to your API and only lets valid token holders through.
- Secret Mission: a /me profile endpoint that reads the authenticated user's identity straight from the JWT claims.
Are there any prerequisites?
No prior Go experience is needed. This project walks you through installing Go and every other dependency from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Go and Create Your Project
Every API starts with a working development environment. Before you can build your JWT authentication API, you need Go installed on your Mac and a project directory ready to go.
In this step, you will install Go 1.26, create your project folder, and initialize Go modules. Go modules manage your project's dependencies, so every package you add later gets tracked automatically.
In this step, get ready to:
- Install Go 1.26 on your Mac.
- Create your project directory and initialize Go modules.
- Verify your project files are set up correctly.
Install Go 1.26
Go is the programming language powering this project. You need version 1.26 or higher installed on your Mac before you can write or run any code.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to open it.
- Check if Go is already installed by running this command:
go version
✔️ I see go1.26 or higher
Go is ready. Skip ahead to the next substep.
ⓧ I see an older version
You have Go installed, but you need version 1.26 or higher. The macOS .pkg installer replaces your current version automatically.
- Open go.dev/dl in your browser.
- Download the macOS .pkg installer for Go 1.26. Choose the Apple Silicon (ARM64) or Intel (x86-64) version depending on your Mac.
- Run the downloaded .pkg file and follow the prompts to complete the installation.
- Close your Terminal window and reopen it so the new version takes effect.
- Confirm the upgrade by running:
go version
You should now see go1.26 in the output.
ⓧ Command not found
Go is not installed yet. Let's install it now.
- Open go.dev/dl in your browser.
- Download the macOS .pkg installer for Go 1.26. Choose the Apple Silicon (ARM64) or Intel (x86-64) version depending on your Mac.
- Run the downloaded .pkg file and follow the prompts to complete the installation.
- Close your Terminal window and reopen it so the new version takes effect.
- Confirm the installation by running:
go version
You should see go1.26 in the output.
Still not seeing go1.26?
Make sure you downloaded the correct installer for macOS from go.dev/dl. Your Mac must be running macOS 12 Monterey or newer.
Make sure you closed and reopened your Terminal after installing. The updated PATH won't take effect in an existing Terminal window.
Help me troubleshoot my Go installation on macOS
Create your project directory and initialize Go modules
Every Go project starts with a go.mod file that declares the project's module name and tracks dependencies. You create this file with the go mod init command.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir go-auth-api
cd go-auth-api
- Initialize Go modules for your project by running:
go mod init go-auth-api
What does this command do?
- go mod init go-auth-api creates a go.mod file in your current directory.
- This file records your module name (go-auth-api) and the Go version. Any dependencies you add in later steps will also be tracked here automatically.
- Confirm the go.mod file was created by running:
ls
You should see go.mod listed in the output.
Don't see go.mod?
Make sure you ran go mod init go-auth-api from inside the go-auth-api folder, not from the Desktop.
If you see an error about the go command not being found, revisit the install substep above and make sure go version shows go1.26.
Help me troubleshoot go mod init not creating a go.mod file
Verify the go.mod file in Vim
Before writing any code, let's confirm that go.mod has the right contents.
Before you open the file, what do you expect to see inside it? Think about what information go mod init would need to record.
- Open go.mod in Vim by running:
vim go.mod
You should see two lines: module go-auth-api at the top, and go 1.26 below it.
What is go.mod?
go.mod is the configuration file for Go modules. It declares your project's module path and the minimum Go version required to build it.
As you add third-party packages in later steps, Go will update this file automatically to track them.
- Press :q and then Enter to exit Vim.
Stuck in Vim?
If you accidentally edited the file and Vim warns you about unsaved changes, type :q! and press Enter to force quit without saving.
✔️ Awesome, I've got everything!
Make sure you have exited Vim and are back at the terminal prompt inside your go-auth-api folder.
ⓧ I'd like to double check the full code
Your go.mod file should look exactly like this:
module go-auth-api
go 1.26
Your development environment is locked in. Next up, you will build your first API endpoint and see just how easy it is for anyone to access it without any authentication.
Build an Unprotected Notes API
Your Go environment is set up and your project is initialized. Now it's time to write some code and get your API serving real data.
In this step, you'll build a REST API that serves notes as JSON over HTTP. There is one catch. The endpoint will be completely unprotected, meaning anyone who knows the URL can see everything.
In this step, get ready to:
- Set up an HTTP server on port 8080 using Go's standard library.
- Write a handler that returns a hardcoded JSON array of notes.
- Test the endpoint and see that anyone can access the data without any credentials.
Create main.go and set up the HTTP server
Every Go program starts with a main function, and yours will create an HTTP server that listens for incoming requests. Go's built-in net/http package handles all the HTTP plumbing for you.
- Create and open main.go by running this command in your terminal:
vim main.go
- Press i to enter insert mode in Vim.
- Type or paste the following code to set up the server and register a route:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func main() {
// Create a new HTTP request multiplexer
mux := http.NewServeMux()
// Register the notes endpoint
mux.HandleFunc("GET /notes", handleGetNotes)
// Start the server on port 8080
fmt.Println("Server running on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
What does this code do?
http.NewServeMux() creates a request multiplexer. It's a router that matches incoming URLs to the right handler function.
mux.HandleFunc("GET /notes", handleGetNotes) tells the router to call handleGetNotes whenever someone sends a GET request to /notes. This method-based routing pattern was introduced in Go 1.22.
log.Fatal(http.ListenAndServe(":8080", mux)) starts the server on port 8080. If the server fails to start, log.Fatal prints the error and exits.
The handleGetNotes function doesn't exist yet. You'll write it next, right below the main function.
Write the notes handler
A handler function in Go takes two arguments: a response writer to send data back, and a request object with information about the incoming request.
- Below the closing } of the main function, add a blank line, then type or paste this handler function:
func handleGetNotes(w http.ResponseWriter, r *http.Request) {
// Hardcoded notes to serve as sample data
notes := []map[string]string{
{"id": "1", "title": "Learn Go", "body": "Go is a great language for backend development"},
{"id": "2", "title": "Build an API", "body": "REST APIs are the backbone of modern web apps"},
}
// Send the notes back as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
What does this handler do?
- notes is a slice of maps holding two sample notes. In a real app, this data would come from a database.
- w.Header().Set("Content-Type", "application/json") tells the client that the response body is JSON.
- json.NewEncoder(w).Encode(notes) converts the Go data structure into JSON and writes it directly to the response.
- Press Esc to exit insert mode.
- Type :wq and press Enter to save and exit Vim.
- Confirm the file was created by running:
ls
You should see main.go alongside your existing go.mod file.
Don't see main.go?
- Make sure you typed :wq (with the colon) to save and quit Vim. Pressing Esc alone exits insert mode but does not save.
- Check that your terminal is still in the go-auth-api directory by running pwd.
- Still stuck? Help me figure out why main.go wasn't created in my go-auth-api directory.
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.go and the file is closed in Vim.
ⓧ I'd like to double check the full code
Here is the complete main.go file. Open it again with vim main.go and compare your code against this:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /notes", handleGetNotes)
fmt.Println("Server running on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func handleGetNotes(w http.ResponseWriter, r *http.Request) {
notes := []map[string]string{
{"id": "1", "title": "Learn Go", "body": "Go is a great language for backend development"},
{"id": "2", "title": "Build an API", "body": "REST APIs are the backbone of modern web apps"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
Run the server and test with curl
Your code is written. Time to run the server and see your API in action.
- Start the server by running this command:
go run main.go
You should see Server running on http://localhost:8080 printed in your terminal. The server is now running and waiting for requests.
Your terminal is now occupied
The server process takes over your current terminal window. It will keep running until you stop it with Ctrl+C. You need a second terminal to send requests to the server.
Seeing an error instead?
- If you see a syntax error, reopen main.go with vim main.go and compare your code against the full-code tab above. Check for missing commas, brackets, or import statements.
- If you see an address-already-in-use error, another process is using port 8080. Close any other servers you might have running.
- Still stuck? Help me debug why go run main.go is failing.
- Open a new terminal window by pressing Cmd+T (opens a new tab in Terminal).
- Navigate back to your project directory by running:
cd ~/Desktop/go-auth-api
Before you send a request, think about this: what credentials or authentication does the /notes endpoint require? What do you think will happen when you request it?
- Send a request to your notes endpoint by running:
curl http://localhost:8080/notes
You see the full JSON notes array returned immediately. No username, no password, no token. Nothing stopped you from accessing the data.
Why is this a problem?
Right now, anyone who knows the URL can see all the notes. In a real application, this data could be private messages, financial records, or personal information.
This is the exact gap you'll close over the next few steps. By the end of this project, this same endpoint will reject anyone who doesn't present a valid JWT token.
Your API is live and serving data. The problem is obvious: zero protection. In the next step, you'll add user registration with securely hashed passwords to start building the authentication layer.
Add User Registration with Password Hashing
Your notes endpoint from Step 2 returns data to anyone who sends a request. That wide-open access is the gap you felt firsthand when you curled the endpoint with zero credentials.
Before you can lock it down, you need users. In this step, you'll build a registration endpoint that accepts an email and password, hashes the password with bcrypt, and stores the user in memory. No password is ever saved as plain text.
In this step, get ready to:
- Install the golang.org/x/crypto package for password hashing.
- Add data structures to store registered users in memory.
- Write a registration handler that validates input, hashes passwords, and stores users.
Install the bcrypt package
Your registration handler will need bcrypt to hash passwords. The bcrypt package lives in Go's extended standard library, so you install it as a separate dependency.
- If your server from Step 2 is still running, press Ctrl+C in your terminal to stop it.
- Install the bcrypt package by running this command:
go get golang.org/x/crypto
- Confirm the dependency was downloaded by running:
ls go.sum
You should see go.sum listed in the output. This file tracks the exact checksums of your dependencies.
Don't see go.sum?
Make sure you're in the go-auth-api directory. Run pwd to check your current location.
If you see a network error, check that you have an internet connection and try the command again.
Help me troubleshoot why go get isn't working.
Add the user data structures
Before writing the registration handler, you need data structures to represent a user and to store registered users in memory.
- Open main.go in Vim by running:
vim main.go
- Find the import block at the top of the file. Press i to enter insert mode and add "sync" on a new line below "net/http", so your import block looks like this:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
)
Why just sync for now?
Go refuses to compile if an import is unused. You'll add the bcrypt import in the next substep when you write the handler that actually uses it.
- Below the closing ) of the import block and above func main(), add the following data structures:
// User represents a registration request
type User struct {
Email string `json:"email"`
Password string `json:"password,omitempty"`
}
// UserStore holds registered users with thread-safe access
type UserStore struct {
mu sync.RWMutex
users map[string]string
}
// Initialize a global store with an empty user map
var store = &UserStore{
users: make(map[string]string),
}
What do these structs do?
User defines the shape of a registration request. The json tags tell Go how to decode incoming JSON fields. The omitempty on Password means the password is excluded from JSON responses when empty.
UserStore wraps a simple map with a sync.RWMutex. The mutex ensures that concurrent HTTP requests don't corrupt the user data by reading and writing at the same time.
- Save the file in Vim by pressing Esc, then typing :w and pressing Enter.
- Verify the file compiles by running this from inside Vim:
:!go build
You should see no errors. Press Enter to return to your file.
Seeing a compile error?
Check that "sync" is inside the import block's parentheses, not outside them. Also make sure the struct fields match exactly, including the backtick-wrapped json tags.
Help me fix a compile error after adding User and UserStore structs to my Go project.
Write the registration handler
The registration handler decodes the incoming request, validates the data, hashes the password, and stores the user. Because this function has several responsibilities, you'll add it in two parts.
- First, add the bcrypt import. Find your import block and add "golang.org/x/crypto/bcrypt" below "sync", so your import block looks like this:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"golang.org/x/crypto/bcrypt"
)
Why the blank line in imports?
Go convention groups standard library imports together, separated by a blank line from external packages. This makes it easy to see which dependencies are built-in and which were installed.
- Scroll to the bottom of handleGetNotes. Below its closing brace, add the first part of the registration handler:
func handleRegister(w http.ResponseWriter, r *http.Request) {
// Decode the JSON request body into a User
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Reject requests missing email or password
if user.Email == "" || user.Password == "" {
http.Error(w, "email and password are required", http.StatusBadRequest)
return
}
// Check if this email is already registered
store.mu.RLock()
_, exists := store.users[user.Email]
store.mu.RUnlock()
if exists {
http.Error(w, "user already exists", http.StatusConflict)
return
}
What does this code do?
json.NewDecoder(r.Body).Decode(&user) reads the raw JSON from the request and fills in the User struct fields. If the JSON is malformed, it returns a 400 error immediately.
The validation check ensures both email and password are present. The duplicate check uses RLock() (a read lock) to safely check the user map without blocking other readers.
- Continue directly below the code you just added. Add the second part of the handler, which hashes and stores the password:
// Hash the password with bcrypt at cost 12
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Store the hashed password in the user map
store.mu.Lock()
store.users[user.Email] = string(hashedPassword)
store.mu.Unlock()
// Return a 201 Created response with the user's email
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "user registered successfully",
"email": user.Email,
})
}
What does this code do?
bcrypt.GenerateFromPassword takes the raw password and a cost factor (12), and returns a one-way hash. Even if someone accesses the stored data, they cannot reverse the hash back into the original password.
Cost 12 means bcrypt runs 2^12 (4,096) iterations of its hashing algorithm. This makes brute-force attacks slow while keeping registration fast enough for users.
The store uses a full Lock() (write lock) when adding a user, which blocks all other reads and writes until the operation completes.
Why cost 12 instead of the default?
The bcrypt default cost is 10, which is too fast on modern hardware to resist brute-force attacks. Cost 12 takes roughly 250-300ms per hash, which balances security with a snappy registration experience.
Register the route and test registration
The handler function exists, but Go's HTTP server doesn't know about it yet. You need to register a route that maps incoming requests to your handler.
- Scroll to the main function. Find this line:
mux.HandleFunc("GET /notes", handleGetNotes)
- Add the registration route on a new line directly below it, so the two lines look like this:
mux.HandleFunc("GET /notes", handleGetNotes)
mux.HandleFunc("POST /register", handleRegister)
- Save and quit Vim by pressing Esc, then typing :wq and pressing Enter.
- Start the server by running:
go run main.go
You should see Server running on http://localhost:8080 in the output.
Seeing a compile error?
Make sure the handleRegister function has a matching opening and closing brace. Check that "golang.org/x/crypto/bcrypt" is inside the import block, separated from the standard library imports by a blank line.
Help me fix a compile error in my Go registration handler.
Now test the registration endpoint. Before you run the next command, what status code do you think a successful registration should return?
- Open a second terminal window. Press Cmd+Space (macOS), type Terminal, and press Enter to open it.
- Register a test user by running:
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
You should see a 201 Created response with this JSON:
{"email":"test@example.com","message":"user registered successfully"}
What happened to the password?
Notice the response contains the email but not the password. The omitempty json tag on the Password field means Go excludes it when encoding the response, since the handler never sets it on the outgoing map. The actual password was hashed with bcrypt and stored in memory as an unreadable hash string.
- Try registering the same user again by running the same curl command:
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
This time you see user already exists with a 409 Conflict status. The duplicate check is working.
Getting connection refused?
Make sure the server is still running in your first terminal window. You should see Server running on http://localhost:8080 in that terminal. If the server crashed, check for compile errors and re-run go run main.go.
Help me troubleshoot why my registration endpoint isn't responding.
✔️ Awesome, I've got everything!
Make sure you've saved main.go and that your server is running.
ⓧ I'd like to double check the full code
Your main.go should now contain the following sections, in order. Scroll through your file and confirm each one is present.
- The package main declaration.
- An import block with encoding/json, fmt, log, net/http, sync, and golang.org/x/crypto/bcrypt.
- The User struct with Email and Password fields.
- The UserStore struct with a sync.RWMutex and users map, plus the global store variable.
- The main function with routes for GET /notes and POST /register.
- The handleGetNotes function (unchanged from Step 2).
- The handleRegister function with JSON decoding, validation, duplicate check, bcrypt hashing at cost 12, and a 201 response.
Users can now register with a hashed password. Next up, you'll give them a way to log in and receive a signed JWT token that proves their identity.
Add Login and JWT Token Generation
In the last step, you gave users a way to register with a securely hashed password. They can create accounts, but there is no way for them to prove who they are on follow-up requests.
This step fixes that gap. You will build a login endpoint that checks the user's credentials and, if they match, hands back a signed JWT (JSON Web Token). That token acts as the user's proof of identity for every future request.
In this step, get ready to:
- Install the JWT library and define the token types your API needs.
- Write a login handler that verifies credentials and returns a signed JWT.
- Test the login flow with valid and invalid passwords.
Install the JWT library and set up token types
A JWT is a compact, signed string that carries data (called claims) about the user. Your server signs the token with a secret key, and any future request can present the token to prove identity without re-entering a password.
- Install the golang-jwt library by running this command in your terminal:
go get -u github.com/golang-jwt/jwt/v5
- Confirm the dependency was added by running:
cat go.mod
You should see github.com/golang-jwt/jwt/v5 listed alongside golang.org/x/crypto in the require block.
Not seeing the new dependency?
Make sure you are inside the go-auth-api directory. Run pwd to check your current location.
help me debug a go get command that did not update my go.mod
Now you need to add two things to main.go: a secret key for signing tokens and a struct that defines what data each token carries.
- Open main.go in Vim by running:
vim main.go
- Find the import block at the top of the file.
- Replace the entire import block with this updated version that adds the JWT import:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
What changed in the imports?
You added one new line: "github.com/golang-jwt/jwt/v5". This is the library that creates and verifies JWT tokens. It sits in the second group with bcrypt because both are external dependencies.
- Right below the closing parenthesis of the import block, above the User struct, add this signing key:
// Secret key used to sign and verify JWT tokens
var jwtSecret = []byte("my-secret-key-change-in-production")
- Scroll down past the var store block. Right below it, above func main(), add this struct:
// Claims defines the data carried inside each JWT
type Claims struct {
Email string `json:"email"`
jwt.RegisteredClaims
}
What do jwtSecret and Claims do?
jwtSecret is the key your server uses to sign every token. Anyone with this key can forge valid tokens, so in production you would load it from an environment variable instead of hardcoding it.
The Claims struct defines what data rides inside each token. It includes the user's Email field plus standard fields like expiry time, provided by the embedded jwt.RegisteredClaims.
- Save the file in Vim by pressing Esc, then typing :w and pressing Enter.
- Verify your code compiles by running this in a separate terminal tab:
go build
If the command prints nothing and returns to the prompt, your code compiles cleanly.
Seeing an import error?
Make sure the JWT import path is exactly "github.com/golang-jwt/jwt/v5" with the /v5 suffix. Also confirm the Claims struct references jwt.RegisteredClaims. If the import is correct but unused, Go will reject it.
help me fix my Go import error
Write the login handler (credential verification)
The login handler has two jobs: verify the user's credentials, then create and return a signed JWT. You will build it in two parts, starting with credential verification.
- In main.go, scroll to the bottom of the file, below the closing brace of handleRegister. Add the first part of the login handler:
func handleLogin(w http.ResponseWriter, r *http.Request) {
// Decode the login request body
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Look up the user's hashed password
store.mu.RLock()
hashedPassword, exists := store.users[user.Email]
store.mu.RUnlock()
// Return a generic error to prevent user enumeration
if !exists {
http.Error(w, "invalid email or password", http.StatusUnauthorized)
return
}
// Verify the password against the stored hash
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(user.Password)); err != nil {
http.Error(w, "invalid email or password", http.StatusUnauthorized)
return
}
Why is the same error message used for both checks?
Both the "user not found" and "wrong password" paths return the same "invalid email or password" message. This is deliberate. If the error were different for each case, an attacker could probe your API to discover which email addresses have accounts. The generic message blocks that technique.
The function is not finished yet. The next part generates the JWT after the credentials pass.
- Before adding the token code, you need the "time" import. Scroll back up to your import block and add "time" on a new line after "sync".
- Then scroll back down to the end of the handleLogin function. Directly below the last return statement and closing brace, add this code to complete the function:
// Create JWT claims with a 15-minute expiry
expirationTime := time.Now().Add(15 * time.Minute)
claims := &Claims{
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "go-auth-api",
},
}
// Sign the token with the HMAC secret key
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtSecret)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Return the signed token as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": tokenString,
})
}
What does the token generation code do?
expirationTime sets the token to expire 15 minutes from now. Short-lived tokens limit the damage if one is ever leaked.
The Claims struct packs the user's email and standard fields (expiry, issue time, issuer) into the token payload. jwt.NewWithClaims creates the token using the HS256 signing algorithm, and SignedString signs it with your jwtSecret.
- Save main.go in Vim (Esc, then :w and Enter).
- Verify compilation by running:
go build
No output means the code compiles successfully.
Seeing an "undefined: time" error?
Double check that you added "time" to your import block. It should sit after "sync" in the standard library group. Also make sure the closing brace } of handleLogin is present at the very end of the function.
help me fix this Go compilation error
Register the login route and test
The handler is ready, but it is not connected to any URL yet. You need to register it as a route in the main function.
- In main.go, scroll to the func main() block and find this line:
mux.HandleFunc("POST /register", handleRegister)
- Add this new route directly below it:
mux.HandleFunc("POST /login", handleLogin)
- Save the file (Esc, :w, Enter).
- If your server from the last step is still running, stop it with Ctrl+C in that terminal tab.
- Start the server again by running:
go run main.go
You should see Server running on http://localhost:8080 in the terminal.
The login endpoint needs a registered user to work. In a new terminal tab, register a test user first.
- Register a test user by running:
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
You should see a "user registered successfully" response.
Before you run the next command, think about what you expect. You are sending the correct email and password. Do you think you will get a token back, or an error?
- Log in with the test user by running:
curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
You should see a JSON response containing a "token" field with a long string of characters. That string is your signed JWT.
Copy that token
You will need the token value from this response in the next step. Copy it now and keep it somewhere handy.
Now test what happens when the password is wrong.
- Try logging in with an incorrect password by running:
curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"wrongpassword"}'
You should see invalid email or password with no hint about whether the email or the password was the problem. That generic message is the anti-enumeration pattern you built.
Not getting a token back?
Make sure you registered a user first. The server stores users in memory, so if you restarted the server between steps, those users are gone. Register again before logging in.
Also check that the route is POST /login (not GET /login) in your main function.
help me debug my login endpoint
✔️ Awesome, I've got everything!
Great. Double check that you have saved main.go.
ⓧ I'd like to double check the full code
Scroll through main.go from top to bottom and confirm each section is present, in this order:
- The import block with "time", "github.com/golang-jwt/jwt/v5", and "golang.org/x/crypto/bcrypt".
- var jwtSecret right after the import block, before the User struct.
- The User struct, UserStore struct, and var store (unchanged from the previous step).
- The Claims struct with Email and embedded jwt.RegisteredClaims.
- func main() with three routes: GET /notes, POST /register, and POST /login.
- handleGetNotes (unchanged).
- handleRegister (unchanged).
- handleLogin with credential verification, claims creation, token signing, and JSON response.
You now have a working login system that issues signed JWT tokens. The notes endpoint is still wide open, though. In the next step, you will build middleware that checks those tokens and blocks anyone who does not have one.
Protect Routes with Auth Middleware
You have registration and login working. Users can create accounts with hashed passwords and receive signed JWTs when they log in.
There is one glaring problem left. The /notes endpoint you built in Step 2 is still wide open. Anyone who runs a curl command against it gets the data back with zero credentials. In this step, you will write middleware that sits between every incoming request and your handler, rejecting anyone without a valid JWT.
In this step, get ready to:
- Write a requireAuth middleware function that validates JWT tokens on incoming requests.
- Wrap the notes endpoint so only authenticated users can access it.
- Test the before-and-after contrast between unauthenticated and authenticated requests.
Write the requireAuth middleware function
Middleware in Go is a function that wraps a handler. It runs its own logic first, and only calls the original handler if the checks pass. Your requireAuth function will take an http.HandlerFunc and return a new http.HandlerFunc with authentication checks baked in.
Before writing the function, you need a new import. The strings package will strip the Bearer prefix from the authorization header.
- In main.go, find the import block at the top of the file.
- Find this line:
"net/http"
- Add "strings" on a new line directly below it, so the block now looks like this:
"net/http"
"strings"
Now you are ready to write the middleware function itself.
- Scroll to the very bottom of main.go, below the closing brace of handleLogin.
- Add the requireAuth function by copying the following code:
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract the Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "authorization header required", http.StatusUnauthorized)
return
}
// Strip the "Bearer " prefix to get the raw token
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
return
}
// Parse the token and validate its claims
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
}
}
What does this code do?
This function acts as a gatekeeper that runs before any wrapped handler.
- r.Header.Get("Authorization") pulls the token from the request header. If the header is missing entirely, the request is rejected with a 401.
- strings.TrimPrefix strips the Bearer prefix. If the prefix was not there (the string is unchanged), the format is wrong and the request is rejected.
- jwt.ParseWithClaims decodes the token, checks the signature against jwtSecret, and verifies the expiry time. If anything fails, the request is rejected.
- next.ServeHTTP(w, r) only runs if every check passes. This is the line that actually calls the original handler.
Why check the signing method?
The line token.Method.(*jwt.SigningMethodHMAC) prevents a class of attacks called algorithm substitution. An attacker can craft a token with a different alg header to trick the parser into accepting an unsigned token.
By asserting the method is specifically HMAC, you guarantee only tokens signed with your secret are accepted.
- Save main.go in Vim by pressing Esc, then typing :w and pressing Enter.
Seeing a syntax error when you save?
- Make sure "strings" is inside the import block's parentheses, on its own line after "net/http".
- Check that the requireAuth function is placed OUTSIDE the closing brace of handleLogin, not nested inside it.
- Count your curly braces. The function has three closing braces at the end: one for the if block, one for the inner func, and one for requireAuth itself.
Help me debug a syntax error in my requireAuth middleware function in Go.
Wrap the notes route with middleware
The requireAuth function exists, but nothing uses it yet. You need to wrap the handleGetNotes handler so the middleware runs on every request to /notes.
- In main.go, scroll up to the main function and find this line:
mux.HandleFunc("GET /notes", handleGetNotes)
- Replace handleGetNotes with requireAuth(handleGetNotes), so the line now looks like this:
mux.HandleFunc("GET /notes", requireAuth(handleGetNotes))
What is this wrapping doing?
requireAuth(handleGetNotes) passes handleGetNotes into requireAuth as the next parameter. The returned function checks the JWT first. Only if the token is valid does it call next.ServeHTTP, which runs the original notes handler.
- Save main.go and exit Vim by pressing Esc, then typing :wq and pressing Enter.
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.go before continuing.
ⓧ I'd like to double check the full code
Your main.go is now over 30 lines, so here is a checklist of what should be present, in order from top to bottom:
- The import block with "strings" added after "net/http".
- The jwtSecret variable, User struct, UserStore struct, store variable, and Claims struct (unchanged from Step 4).
- The main function with the GET /notes route now using requireAuth(handleGetNotes).
- handleGetNotes, handleRegister, and handleLogin functions (unchanged from Steps 2-4).
- The new requireAuth function at the very bottom of the file.
Test the protected endpoint
Time to see the middleware in action. You will restart the server, then hit the notes endpoint twice. Once without a token, and once with a valid one.
- If your server is still running from Step 4, press Ctrl+C in that terminal to stop it.
- Restart the server by running:
go run main.go
You should see Server running on http://localhost:8080 printed in the terminal.
Seeing a compile error?
- If Go reports strings is undefined, check that you added "strings" to the import block (with quotes).
- If Go reports requireAuth is undefined, make sure the function is at the top level of the file (not nested inside another function).
Help me fix a compile error in my Go auth middleware.
- Open a second terminal window.
Think back to Step 2. When you ran curl http://localhost:8080/notes, the notes came back immediately with no credentials. Before you run the same command now, what do you think will happen?
- Test the unauthenticated request by running:
curl http://localhost:8080/notes
You see authorization header required in the response. The same endpoint that was wide open in Step 2 is now locked down. The middleware rejected the request with a 401 because no Authorization header was present.
Now prove that authenticated requests still get through. Since the in-memory store resets every time you restart the server, you need to register and log in again.
- Register a user by running:
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
- Log in to get a fresh token by running:
curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
- Copy the long token string from the JSON response.
- Hit the protected notes endpoint with your token by running:
curl http://localhost:8080/notes -H "Authorization: Bearer [[JWT_TOKEN="your token from the login response"]]"
You see the JSON notes array returned successfully. The middleware checked your token, confirmed it was valid, and let the request through to handleGetNotes.
What should I see?
Without a token, the response is authorization header required with a 401 status. With a valid token, the response is the JSON array containing the two notes (Learn Go and Build an API).
Still getting a 401 with your token?
- Make sure there is a space between Bearer and the token string in the header value. The middleware expects Bearer <token> with exactly one space.
- Tokens expire after 15 minutes. If you waited too long, log in again to get a fresh token.
- Check that you copied the full token string from the login response. Missing even one character will invalidate it.
Help me debug my JWT authentication middleware in Go.
Your API now has a complete authentication flow. Users register, log in, receive a token, and use that token to access protected data. In the Secret Mission, you will take this one step further by reading the authenticated user's identity from the token claims.
Secret mission
Add a Profile Endpoint That Reads JWT Claims
Your middleware validates tokens, but it throws away the user information inside them. Can you build a /me endpoint that reads the authenticated user's email straight from the JWT and returns personalized profile data?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, pause your work to come back later, or delete them entirely.
Resources you used:
- The go-auth-api project directory on your Desktop (contains main.go and go.mod).
Keep everything running
No action needed. Choose this if you plan to keep building on your authentication API or want to revisit the code later.
- Your go-auth-api folder stays on your Desktop, ready for you to pick up.
- Go stays installed on your machine for future projects.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your project files stay on disk.
- If your Go server is still running, press Ctrl+C in the terminal to stop it.
- Your go-auth-api folder and all its files remain on your Desktop.
- To resume, open your terminal, navigate back to ~/Desktop/go-auth-api, and run go run main.go.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- If your Go server is still running, press Ctrl+C in the terminal to stop it.
- Delete the project directory by running this command:
rm -rf ~/Desktop/go-auth-api
- Confirm the folder is gone by running:
ls ~/Desktop | grep go-auth-api
You should see no output, confirming the folder has been removed.
Nice Work!
Nice Work!
You just built a complete authentication API from scratch in Go. Your API registers users, issues signed JWTs, and blocks unauthenticated requests at the middleware layer.
You've learned how to:
- Build a REST API in Go using only the standard library for HTTP handling, with method-based routing on Go's enhanced ServeMux.
- Hash passwords with bcrypt so they are never stored in plain text, and generate signed JWTs with custom claims and a 15-minute expiry.
- Build auth middleware that validates tokens, verifies the signing method to prevent algorithm substitution attacks, and rejects unauthenticated requests with a 401.
- Secret Mission: Used Go's context to pass authenticated user data from middleware to handlers, powering a personalized profile endpoint.
Ready to quiz yourself?