Build a Task Manager REST API in Go
Build an authenticated REST API in Go with Chi, JWT, and PostgreSQL.
Introduction
30 Second Summary
Every app you use daily relies on an API running somewhere behind the scenes. When you check your to-do list, update a task, or mark something complete, an API handles the request and keeps your data safe.
In this project, you will build a Task Manager REST API in Go from scratch. You will create endpoints for user registration, login, and full task CRUD, all protected by JWT authentication and backed by a PostgreSQL database.
What You'll Build
You will start your server, fire off requests with curl, and watch JSON responses flow back as you register users, log in, create tasks, and verify that each user can only see their own data.
By the end of this project, you'll have:
- A running Go API with Chi router that you can hit with curl and get back clean JSON responses for creating, listing, updating, and deleting tasks.
- A PostgreSQL database running in Docker that keeps your tasks alive even after you restart the server.
- A JWT authentication flow where you register, log in, and prove that User A cannot see User B's tasks.
- Secret Mission: Add task filtering by status and a search endpoint that queries tasks by title.
Are there any prerequisites?
This project assumes you have basic programming knowledge (a JavaScript or TypeScript background is fine). You will need Docker, Go 1.26, and Neovim configured with gopls already installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
You're building a REST API from scratch in Go with real authentication and database persistence. Take a moment to put into words what you want to prove you can do by the end.
Set Up the Project
Every Go API starts as an empty folder. Before you can write handler code or connect to a database, you need a Go module to track your dependencies and a running PostgreSQL instance to store data.
In this step you will initialize a Go module, install four core libraries, spin up a PostgreSQL container with Docker, and write the smallest possible server that proves your router works.
In this step, get ready to:
- Initialize the Go module and install project dependencies.
- Start a PostgreSQL database in a Docker container.
- Write a minimal server with a health check endpoint.
Initialize the Go module and install dependencies
A Go module is a collection of packages that share a single dependency tree. Creating one gives you a go.mod file that tracks every library your project uses.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir taskmanager
cd taskmanager
- Initialize your Go module by running:
go mod init taskmanager
You should see a confirmation that go.mod was created.
- Confirm the file exists by running:
ls
You should see go.mod listed in the output.
- Install the four libraries your project needs by running these commands:
go get github.com/go-chi/chi/v5@v5.3.0
go get github.com/jackc/pgx/v5@v5.10.0
go get github.com/golang-jwt/jwt/v5@v5.3.1
go get golang.org/x/crypto@v0.54.0
What do these libraries do?
- chi is a lightweight HTTP router that uses standard Go handler signatures.
- pgx is the production-standard PostgreSQL driver for Go with built-in connection pooling.
- golang-jwt handles creating and validating JSON Web Tokens for authentication.
- x/crypto provides bcrypt for securely hashing passwords.
Each go get command downloads the library and records it in your go.mod file.
- Create the project directory structure by running:
mkdir -p cmd/api internal/handler internal/middleware internal/store
- Confirm the directories were created by running:
ls cmd internal
You should see api under cmd, and handler, middleware, store under internal.
Why this folder structure?
Go projects use cmd/ for executable entry points and internal/ for private packages that other projects cannot import. This is the conventional layout you will see in production Go codebases.
Getting a "module not found" error on go get?
Make sure you ran go mod init taskmanager first. The go get command requires a go.mod to exist in the current directory.
Also confirm you are inside the taskmanager folder by running pwd. The path should end with /taskmanager.
Help me fix a go get error in my taskmanager project.
Start PostgreSQL in Docker
Your API needs a database to persist tasks and user accounts. Running PostgreSQL in a Docker container keeps it isolated from your system and easy to tear down later.
- Start a PostgreSQL 17 container by running this command:
docker run --name taskmanager-db -e POSTGRES_USER=taskuser -e POSTGRES_PASSWORD=taskpass -e POSTGRES_DB=taskmanager -p 5432:5432 -d postgres:17
What does this command do?
- --name taskmanager-db gives the container a memorable name you can reference later.
- -e flags set environment variables that PostgreSQL uses to create the database, user, and password on first boot.
- -p 5432:5432 maps the container's PostgreSQL port to your local machine so your Go code can connect.
- -d postgres:17 runs the official PostgreSQL 17 image in detached (background) mode.
Docker will print a long container ID when it starts successfully.
- Verify the container is running by running:
docker ps
You should see a row with taskmanager-db in the NAMES column and Up in the STATUS column.
Container not showing in docker ps?
If the container exited immediately, check its logs by running docker logs taskmanager-db. A common cause is port 5432 already being used by another process.
If you see a "name already in use" error, a previous container with that name exists. Remove it with docker rm taskmanager-db and run the docker run command again.
Help me get my PostgreSQL Docker container running.
Write the minimal server
With dependencies installed and the database running, you can write the entry point for your API. This minimal server proves that the Chi router is working before you add real endpoints.
- Open cmd/api/main.go in Neovim by running:
nvim cmd/api/main.go
- Add the following code to the file:
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
// Health check endpoint for verifying the server is alive
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Configure and start the HTTP server
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
fmt.Println("server started on :8080")
log.Fatal(srv.ListenAndServe())
}
What does this code do?
- chi.NewRouter() creates the HTTP router that will dispatch incoming requests to handler functions.
- r.Get("/health", ...) registers a GET endpoint that returns a JSON status response. This is a quick way to verify the server is reachable.
- http.Server wraps the router with read and write timeouts. Timeouts prevent a slow client from holding a connection open forever.
- log.Fatal(srv.ListenAndServe()) starts accepting connections. If the server fails to bind port 8080, it logs the error and exits.
- Save the file and exit Neovim (:wq).
Before you run the server, what do you think you will see printed in the terminal?
- Start the server by running:
go run cmd/api/main.go
You should see server started on :8080 printed in the terminal. The process keeps running because the server is listening for requests.
- Open a second terminal tab or window and test the health endpoint by running:
curl http://localhost:8080/health
You should see {"status":"ok"} returned as the response.
- Switch back to the first terminal and press Ctrl+C to stop the server.
Seeing "address already in use"?
Another process is using port 8080. Find it by running lsof -i :8080 (macOS/Linux) and stop it, or change the Addr in your code to a different port like :8081.
If you see a compilation error, check that your import paths match exactly. The gopls language server in Neovim will underline import errors in red.
Help me fix my Go server startup error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved cmd/api/main.go and confirmed the server prints server started on :8080 when you run it.
ⓧ I'd like to double check the full code
Here is the complete cmd/api/main.go for this step:
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
// Health check endpoint for verifying the server is alive
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Configure and start the HTTP server
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
fmt.Println("server started on :8080")
log.Fatal(srv.ListenAndServe())
}
Your project skeleton is live. You have a running Go server with a working router and a PostgreSQL database waiting for data. Next up, you will build the task CRUD endpoints so the API actually does something useful.
Build the Task API with Hardcoded Data
Your server is running and responding to health checks. That proves Chi is routing requests correctly.
Now you need actual task endpoints that return JSON. In this step, you will build a baby version of the Task API using hardcoded data stored in memory. This gets your routing and handler patterns working fast, but it comes with a limitation you will discover firsthand.
In this step, get ready to:
- Define a Task model with JSON struct tags.
- Write handler functions for listing and creating tasks.
- Test the full CRUD cycle with curl and discover the in-memory limitation.
Define the Task model
Every task needs a consistent shape. In Go, you define that shape as a struct with JSON tags so the fields serialize cleanly when sent over HTTP.
- Create the file internal/model/task.go by running:
touch internal/model/task.go
- Open internal/model/task.go in Neovim and add the following code:
package model
import "time"
// Task represents a single to-do item
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// CreateTaskRequest is the expected body for POST /api/tasks
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
}
// UpdateTaskRequest uses pointers so missing fields stay nil
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
}
What does this code do?
The Task struct defines what a task looks like in your system. The backtick tags (`json:"id"`) tell Go's JSON encoder which key names to use when converting to JSON.
UpdateTaskRequest uses pointer fields (*string) so you can distinguish between "the user sent an empty string" and "the user didn't send this field at all." A nil pointer means the field was not included in the request.
- Save the file.
- Verify the model package compiles by running:
go build ./internal/model/
No output means the package compiled successfully.
Seeing a compile error?
Check that the file starts with package model (not package main). Also confirm you have import "time" at the top since CreatedAt uses time.Time.
help me fix a Go compile error in my model package
✔️ Awesome, I've got everything!
Great. Make sure internal/model/task.go is saved before continuing.
ⓧ I'd like to double check the full code
package model
import "time"
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
}
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
}
Write the List and Create handlers
Handlers are functions that receive an HTTP request and write a response. You will create a TaskHandler struct that holds tasks in a plain Go slice. No database yet.
- First, create a minimal store placeholder so the handler's import compiles. Create internal/store/store.go with this content:
package store
// TaskStore will hold the database pool later
type TaskStore struct{}
Why create an empty store now?
The handler struct references *store.TaskStore so it's ready to accept a real database connection in a future step. For now, the field stays nil while you use the in-memory slice. Go requires the imported package to exist at compile time, so this placeholder satisfies that.
- Create internal/handler/task.go and add the package declaration, imports, struct, and constructor:
package handler
import (
"encoding/json"
"net/http"
"sync"
"taskmanager/internal/model"
"taskmanager/internal/store"
)
// TaskHandler serves task CRUD endpoints
type TaskHandler struct {
taskStore *store.TaskStore
mu sync.Mutex
tasks []model.Task
}
// NewTaskHandler creates a handler with one hardcoded task
func NewTaskHandler(taskStore *store.TaskStore) *TaskHandler {
return &TaskHandler{
taskStore: taskStore,
tasks: []model.Task{
{ID: "1", Title: "Buy groceries", Description: "Milk, eggs, bread", Status: "pending", UserID: "fake-user"},
},
}
}
What does this code do?
The TaskHandler struct holds a mutex (sync.Mutex) to protect the slice from concurrent access and a tasks slice that acts as your in-memory database.
NewTaskHandler seeds the slice with one hardcoded task so you have something to see immediately when you list tasks.
- Below the constructor in the same file, add the List and Create methods:
func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
defer h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.tasks)
}
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
h.mu.Lock()
defer h.mu.Unlock()
task := model.Task{ID: "temp", Title: req.Title, Description: req.Description, Status: "pending", UserID: "fake-user"}
h.tasks = append(h.tasks, task)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
}
What does this code do?
List locks the mutex, encodes the entire tasks slice as JSON, and writes it to the response. Every handler follows Go's standard signature: func(w http.ResponseWriter, r *http.Request).
Create decodes the request body into a CreateTaskRequest, builds a Task with a hardcoded ID and user, then appends it to the slice.
- Save internal/handler/task.go.
- Verify everything compiles by running:
go build ./...
No output means all packages compiled cleanly.
Seeing an import error?
Make sure internal/store/store.go exists with package store and type TaskStore struct{}. The handler imports this package, so it must exist even though it is mostly empty right now.
help me fix an import error in my Go task handler
Wire handlers into the router and test
The handlers exist but nobody can reach them yet. You need to register them on the Chi router so incoming HTTP requests get routed to the right function.
- Open cmd/api/main.go in Neovim.
- Find the import block and add this line after the Chi import:
"taskmanager/internal/handler"
- Find the line r := chi.NewRouter() and add this line directly above it:
taskHandler := handler.NewTaskHandler(nil)
- Find the closing brace of the /health handler. Add this route group below it:
r.Route("/api", func(r chi.Router) {
r.Get("/tasks", taskHandler.List)
r.Post("/tasks", taskHandler.Create)
})
What is a route group?
r.Route("/api", ...) creates a sub-router scoped to the /api prefix. Every route registered inside it automatically gets that prefix. So r.Get("/tasks", ...) inside the group responds to GET /api/tasks.
- Save cmd/api/main.go.
- If your server is still running from Step 1, stop it with Ctrl+C. Start it again by running:
go run cmd/api/main.go
You should see server started on :8080 in the terminal.
- Open a second terminal. List all tasks by running:
curl http://localhost:8080/api/tasks
You should see a JSON array with the hardcoded task:
[{"id":"1","title":"Buy groceries","description":"Milk, eggs, bread","status":"pending","user_id":"fake-user","created_at":"0001-01-01T00:00:00Z"}]
- Create a new task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Deploy app","description":"Push to production"}'
You should get back the new task with status pending and a 201 response.
- List tasks again to confirm both tasks exist:
curl http://localhost:8080/api/tasks
You should see two tasks in the array now.
Before you restart the server, what do you think will happen to these tasks if you stop and start the process again?
- Go back to the terminal running the server and press Ctrl+C to stop it.
- Start the server again:
go run cmd/api/main.go
- In your second terminal, list tasks again:
curl http://localhost:8080/api/tasks
The "Deploy app" task you just created is gone. Only the original hardcoded task remains.
Why did my task disappear?
Your tasks live in a Go slice. A slice only exists in the process's memory. When you stop the server with Ctrl+C, the process dies and all its memory is freed. Restarting creates a fresh slice with only the hardcoded seed data.
This is the gap you will close in Step 3 by connecting to PostgreSQL. A database persists data to disk, so tasks survive restarts.
Add Update and Delete handlers
A complete CRUD API needs all four operations. You will add the remaining two handlers that modify and remove tasks from the in-memory slice.
- Open internal/handler/task.go. Add this import inside the import block, after the "net/http" line:
"github.com/go-chi/chi/v5"
- Add the Update and Delete methods at the bottom of the file, below the Create method:
func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
var req model.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
h.mu.Lock()
defer h.mu.Unlock()
for i, t := range h.tasks {
if t.ID == taskID {
if req.Title != nil { h.tasks[i].Title = *req.Title }
if req.Description != nil { h.tasks[i].Description = *req.Description }
if req.Status != nil { h.tasks[i].Status = *req.Status }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.tasks[i])
return
}
}
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
}
What does this code do?
chi.URLParam(r, "id") extracts the {id} segment from the URL path. The handler loops through the slice looking for a matching task, then applies only the fields the client sent (nil pointer fields are skipped).
- Add the Delete method below Update:
func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
h.mu.Lock()
defer h.mu.Unlock()
for i, t := range h.tasks {
if t.ID == taskID {
h.tasks = append(h.tasks[:i], h.tasks[i+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
}
What does this code do?
Delete finds the task by ID and removes it from the slice using Go's slice append trick: append(slice[:i], slice[i+1:]...). It returns 204 No Content on success, meaning "done, nothing to show you."
- Save internal/handler/task.go.
- Now update the route group in cmd/api/main.go. Find the route group you added earlier:
r.Route("/api", func(r chi.Router) {
r.Get("/tasks", taskHandler.List)
r.Post("/tasks", taskHandler.Create)
})
- Add the Put and Delete routes so the group looks like this:
r.Route("/api", func(r chi.Router) {
r.Get("/tasks", taskHandler.List)
r.Post("/tasks", taskHandler.Create)
r.Put("/tasks/{id}", taskHandler.Update)
r.Delete("/tasks/{id}", taskHandler.Delete)
})
- Save cmd/api/main.go.
- Stop the running server with Ctrl+C and restart it:
go run cmd/api/main.go
- In your second terminal, update the hardcoded task's status by running:
curl -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d '{"status":"done"}'
You should see the task returned with "status":"done" while the title and description remain unchanged.
- Delete the task by running:
curl -X DELETE http://localhost:8080/api/tasks/1
You should get an empty response with no body (204 No Content).
- Confirm the task is gone by listing all tasks:
curl http://localhost:8080/api/tasks
You should see an empty array: [].
Getting 404 on PUT or DELETE?
Make sure the route uses {id} in curly braces: r.Put("/tasks/{id}", ...). Chi uses braces for path parameters, not colons. Also confirm you restarted the server after saving.
help me debug 404 errors on my Chi PUT and DELETE routes
✔️ Awesome, I've got everything!
Great. Confirm all your files are saved and the full CRUD cycle works with curl.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in order:
- internal/model/task.go: the Task, CreateTaskRequest, and UpdateTaskRequest structs.
- internal/handler/task.go: package declaration, imports (including "github.com/go-chi/chi/v5"), TaskHandler struct, NewTaskHandler constructor, List, Create, Update, and Delete methods.
- cmd/api/main.go: imports include "taskmanager/internal/handler", taskHandler := handler.NewTaskHandler(nil) before the router, and the r.Route("/api", ...) group with all four routes.
You have a working CRUD API that handles tasks over HTTP. The routing patterns, JSON encoding, and handler signatures are all production Go idioms. Next up, you will connect PostgreSQL so your tasks survive a server restart.
Add PostgreSQL Persistence
You proved your CRUD routes work in the last step. But you also saw the problem: restart the server and every task vanishes.
Tasks disappear because they live only in a Go slice that resets every time the process exits. In this step, you will connect to the PostgreSQL container you already have running and replace that in-memory slice with real SQL queries using pgx.
In this step, get ready to:
- Write a SQL schema and apply it to your Postgres container.
- Create a TaskStore that talks to PostgreSQL with parameterized queries.
- Update the server to open a connection pool and route handler calls through the store.
Write and apply the database schema
Before your Go code can read or write tasks, the database needs tables to hold them. You will create a migration file that defines a users table (needed later for auth) and a tasks table.
- Create the migrations directory and file by running these commands:
mkdir -p migrations
touch migrations/001_create_tables.sql
- Confirm the file exists by running:
ls migrations
You should see 001_create_tables.sql in the output.
- Open migrations/001_create_tables.sql in Neovim and add the following SQL:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(user_id, status);
What does this schema do?
- pgcrypto enables gen_random_uuid() so PostgreSQL generates UUIDs for each row automatically.
- The users table stores accounts. You will not fill it from Go yet, but auth in the next step needs it.
- The tasks table has a foreign key (user_id) pointing at users. This guarantees every task belongs to a real user.
- Indexes on user_id and (user_id, status) make filtered queries fast.
- Save the file, then apply the migration against your running Postgres container:
cat migrations/001_create_tables.sql | docker exec -i taskmanager-db psql -U taskuser -d taskmanager
You should see output lines including CREATE EXTENSION, CREATE TABLE, and CREATE INDEX confirming each statement succeeded.
- Because the tasks table has a foreign key to users, you need a placeholder user before you can insert tasks. Create one by running:
docker exec -i taskmanager-db psql -U taskuser -d taskmanager -c "INSERT INTO users (id, email, password) VALUES ('00000000-0000-0000-0000-000000000001', 'placeholder@example.com', 'nohash');"
You should see INSERT 0 1 confirming the row was added.
Why insert a placeholder user?
The foreign key constraint on tasks.user_id means PostgreSQL rejects any task that references a non-existent user. Since you have not built registration yet, this dummy row lets you test task creation now. You will remove this workaround in Step 4 when real users exist.
Seeing connection refused?
- Confirm the container is running with docker ps. You should see taskmanager-db in the list.
- If it is not listed, start it with docker start taskmanager-db.
Still stuck? Help me troubleshoot my PostgreSQL Docker container
Create the TaskStore
With tables in place, you need Go code that knows how to insert, query, update, and delete rows. This is the store layer, a struct that holds a connection pool and exposes one method per database operation.
- Open internal/store/store.go in Neovim and replace its contents with the package declaration, imports, struct, constructor, and the Create method:
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"taskmanager/internal/model"
)
type TaskStore struct {
pool *pgxpool.Pool
}
func NewTaskStore(pool *pgxpool.Pool) *TaskStore {
return &TaskStore{pool: pool}
}
func (s *TaskStore) Create(ctx context.Context, task *model.Task) error {
query := `INSERT INTO tasks (id, title, description, status, user_id, created_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW())
RETURNING id, created_at`
return s.pool.QueryRow(ctx, query, task.Title, task.Description, "pending", task.UserID).
Scan(&task.ID, &task.CreatedAt)
}
What does this code do?
- TaskStore holds a pgxpool.Pool which manages multiple database connections safely across goroutines.
- The Create method uses parameterized placeholders ($1, $2, etc.) instead of string concatenation. This prevents SQL injection.
- RETURNING id, created_at asks PostgreSQL to send back the generated UUID and timestamp so the Go struct gets populated in one round trip.
- Below the Create method, add the first half of ListByUser which builds the SQL query dynamically:
func (s *TaskStore) ListByUser(ctx context.Context, userID string, status string, search string) ([]model.Task, error) {
query := `SELECT id, title, description, status, user_id, created_at FROM tasks WHERE user_id = $1`
args := []any{userID}
argPos := 2
if status != "" {
query += fmt.Sprintf(" AND status = $%d", argPos)
args = append(args, status)
argPos++
}
if search != "" {
query += fmt.Sprintf(" AND title ILIKE $%d", argPos)
args = append(args, "%"+search+"%")
argPos++
}
query += " ORDER BY created_at DESC"
- Continue directly below (still inside the same function) with the query execution and row scanning:
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var t model.Task
if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.UserID, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
What does ListByUser do?
It builds a SQL query dynamically. The base query always filters by user_id. If status or search are non-empty, it appends additional AND clauses with new parameterized positions. This keeps the query safe from injection while supporting optional filters.
The rows.Next() loop scans each row into a model.Task struct and appends it to the results slice.
- Below ListByUser, add the GetByID and Update methods:
func (s *TaskStore) GetByID(ctx context.Context, id string, userID string) (*model.Task, error) {
query := `SELECT id, title, description, status, user_id, created_at FROM tasks WHERE id = $1 AND user_id = $2`
var t model.Task
err := s.pool.QueryRow(ctx, query, id, userID).
Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.UserID, &t.CreatedAt)
if err != nil {
return nil, err
}
return &t, nil
}
func (s *TaskStore) Update(ctx context.Context, id string, userID string, req *model.UpdateTaskRequest) (*model.Task, error) {
query := `UPDATE tasks SET
title = COALESCE($1, title),
description = COALESCE($2, description),
status = COALESCE($3, status)
WHERE id = $4 AND user_id = $5
RETURNING id, title, description, status, user_id, created_at`
var t model.Task
err := s.pool.QueryRow(ctx, query, req.Title, req.Description, req.Status, id, userID).
Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.UserID, &t.CreatedAt)
if err != nil {
return nil, err
}
return &t, nil
}
What does COALESCE do here?
COALESCE($1, title) means: if the Go code passes nil for that field (the caller did not include it in the request body), keep the existing column value. This lets partial updates work without overwriting fields the caller did not mention.
- Finally, add the Delete method below Update:
func (s *TaskStore) Delete(ctx context.Context, id string, userID string) error {
query := `DELETE FROM tasks WHERE id = $1 AND user_id = $2`
ct, err := s.pool.Exec(ctx, query, id, userID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return fmt.Errorf("task not found")
}
return nil
}
What does RowsAffected check?
Exec returns a CommandTag that reports how many rows the statement touched. If zero rows were deleted, the task either does not exist or does not belong to this user. Returning an error lets the handler respond with a 404.
- Save internal/store/store.go.
Wire the store into main.go and update the handlers
The store is ready, but the server still ignores it. You need main.go to open a connection pool on startup and pass it to the handler. Then the handler needs to call the store instead of the in-memory slice.
- Open cmd/api/main.go and replace its entire contents with the imports and pool setup:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"taskmanager/internal/handler"
"taskmanager/internal/store"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://taskuser:taskpass@localhost:5432/taskmanager?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("unable to connect to database: %v", err)
}
defer pool.Close()
What changed?
- DATABASE_URL is read from the environment with a sensible default that matches your Docker container's credentials.
- pgxpool.New opens a connection pool. If Postgres is unreachable, the server crashes immediately with a clear error instead of silently failing later.
- Still in cmd/api/main.go, add the rest of main below the defer pool.Close() line (the ping, store creation, router, and server start):
if err := pool.Ping(ctx); err != nil {
log.Fatalf("unable to ping database: %v", err)
}
taskStore := store.NewTaskStore(pool)
taskHandler := handler.NewTaskHandler(taskStore)
r := chi.NewRouter()
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
r.Route("/api", func(r chi.Router) {
r.Get("/tasks", taskHandler.List)
r.Post("/tasks", taskHandler.Create)
r.Put("/tasks/{id}", taskHandler.Update)
r.Delete("/tasks/{id}", taskHandler.Delete)
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
fmt.Println("server started on :8080")
log.Fatal(srv.ListenAndServe())
}
- Save cmd/api/main.go.
The server now connects to PostgreSQL. But the handler still uses its in-memory slice. Time to fix that.
- Open internal/handler/task.go and replace its entire contents with the package header, struct, and constructor:
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"taskmanager/internal/model"
"taskmanager/internal/store"
)
type TaskHandler struct {
taskStore *store.TaskStore
}
func NewTaskHandler(taskStore *store.TaskStore) *TaskHandler {
return &TaskHandler{taskStore: taskStore}
}
- Below the constructor, add the List handler method:
func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) {
// Hardcode user ID until auth is added in the next step
userID := "00000000-0000-0000-0000-000000000001"
tasks, err := h.taskStore.ListByUser(r.Context(), userID, "", "")
if err != nil {
http.Error(w, `{"error":"could not fetch tasks"}`, http.StatusInternalServerError)
return
}
if tasks == nil {
tasks = []model.Task{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
- Below List, add the Create handler method:
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Title == "" {
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
return
}
// Use placeholder user until auth middleware exists
task := &model.Task{
Title: req.Title,
Description: req.Description,
UserID: "00000000-0000-0000-0000-000000000001",
}
if err := h.taskStore.Create(r.Context(), task); err != nil {
http.Error(w, `{"error":"could not create task"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
}
- Add the Update and Delete methods at the bottom of the file:
func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
userID := "00000000-0000-0000-0000-000000000001"
taskID := chi.URLParam(r, "id")
var req model.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
task, err := h.taskStore.Update(r.Context(), taskID, userID, &req)
if err != nil {
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID := "00000000-0000-0000-0000-000000000001"
taskID := chi.URLParam(r, "id")
if err := h.taskStore.Delete(r.Context(), taskID, userID); err != nil {
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
What changed in the handler?
- The in-memory slice and mutex are gone. Every method now calls the store, which talks to PostgreSQL.
- The user ID is hardcoded to the placeholder UUID you inserted earlier. In the next step, real authentication will replace this.
- Save internal/handler/task.go.
- Make sure your previous server process is stopped (Ctrl+C in that terminal), then start the server by running:
go run cmd/api/main.go
You should see server started on :8080 with no database errors. The pool connected successfully.
Server crashes with "unable to connect to database"?
- Confirm your Postgres container is running with docker ps. If it is not listed, start it with docker start taskmanager-db.
- Check the default connection string in your main.go matches postgres://taskuser:taskpass@localhost:5432/taskmanager?sslmode=disable exactly.
Need more help? Help me debug my pgx database connection
Now for the real test. Before you run the next commands, think: if you create a task with curl, stop the server, and start it again, will the task still be there?
- In a second terminal, create a task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Deploy to production","description":"Ship the app"}'
You should see a JSON response with a UUID id and a created_at timestamp. PostgreSQL generated both.
- Stop the server with Ctrl+C in the first terminal, then start it again:
go run cmd/api/main.go
- List your tasks by running:
curl http://localhost:8080/api/tasks
Your task is still there. No more vanishing data. PostgreSQL holds everything even when the Go process is not running.
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/store/store.go, cmd/api/main.go, and internal/handler/task.go.
ⓧ I'd like to double check the full code
Your files are long enough that it is easier to verify them by chunk. Scroll up and confirm each of these sections is present, in order:
- migrations/001_create_tables.sql contains the pgcrypto extension, users table, tasks table, and two indexes.
- internal/store/store.go has: package/imports, TaskStore struct + NewTaskStore, Create, ListByUser, GetByID, Update, Delete.
- cmd/api/main.go has: new imports (context, os, pgxpool, store), DATABASE_URL env read, pool creation, ping, NewTaskStore, router, server.
- internal/handler/task.go has: no more sync import or mutex, all methods call h.taskStore, hardcoded user ID 00000000-0000-0000-0000-000000000001.
If any section looks different from the code you typed above, update it to match, then save and re-run go run cmd/api/main.go.
Your API now has real persistence. Tasks survive restarts, updates stick, and deletes are permanent. The one gap is obvious: anyone with curl can hit your endpoints. Next up, you will add user registration and JWT tokens so only authenticated users can touch their own tasks.
Add User Registration and JWT Authentication
Right now, anyone with curl can create, read, update, and delete every task in your database. There is no concept of identity.
In this step you will build a full authentication system: user registration with hashed passwords, login that returns a signed JWT token, and middleware that blocks any unauthenticated request from reaching the task endpoints.
In this step, get ready to:
- Create user registration and login endpoints that return signed JWTs.
- Build authentication middleware that protects the task routes.
- Wire the authenticated user ID into the task handlers so each user only sees their own tasks.
Create the user model and store
Before you can register users you need Go structs to represent them and a store layer to persist them in PostgreSQL.
- Create the file internal/model/user.go and add the user model structs:
package model
import "time"
// User represents a registered account
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Password string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
// RegisterRequest is the JSON body for POST /auth/register
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginRequest is the JSON body for POST /auth/login
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// AuthResponse is returned after successful register or login
type AuthResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
What does this code do?
The User struct maps to the users table you already created. The json:"-" tag on Password ensures the hashed password is never sent back in API responses.
RegisterRequest, LoginRequest, and AuthResponse are thin types that define the shape of JSON coming in and going out.
- Save internal/model/user.go.
Next you need a store that hashes passwords and persists users. Create the file internal/store/user.go and start with the struct, constructor, and the Create method:
- Add the package header, imports, struct, and constructor to internal/store/user.go:
package store
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"taskmanager/internal/model"
)
type UserStore struct {
pool *pgxpool.Pool
}
func NewUserStore(pool *pgxpool.Pool) *UserStore {
return &UserStore{pool: pool}
}
What does this code do?
UserStore follows the same pattern as your TaskStore: it holds a reference to the connection pool and exposes methods that run queries.
- Below the constructor, add the Create method that hashes the password with bcrypt and inserts the user:
func (s *UserStore) Create(ctx context.Context, email string, password string) (*model.User, error) {
// Hash the plaintext password before storing
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
query := `INSERT INTO users (id, email, password, created_at)
VALUES (gen_random_uuid(), $1, $2, NOW())
RETURNING id, email, created_at`
var user model.User
err = s.pool.QueryRow(ctx, query, email, string(hash)).
Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
What does this code do?
bcrypt.GenerateFromPassword produces a one-way hash of the password. Even if the database is compromised, the original passwords cannot be recovered.
The RETURNING clause lets PostgreSQL hand back the generated UUID and timestamp in a single round trip.
- Below the Create method, add FindByEmail for login lookups:
func (s *UserStore) FindByEmail(ctx context.Context, email string) (*model.User, error) {
query := `SELECT id, email, password, created_at FROM users WHERE email = $1`
var user model.User
err := s.pool.QueryRow(ctx, query, email).
Scan(&user.ID, &user.Email, &user.Password, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
What does this code do?
This method loads the full user row including the hashed password. The login handler will compare the incoming plaintext password against this hash using bcrypt.CompareHashAndPassword.
- Save internal/store/user.go.
- Confirm both new files compile by running:
go build ./...
You should see no output, meaning the build succeeded with no errors.
Seeing import errors?
Make sure the package declaration at the top of user.go in the store directory is package store and the model file uses package model. Also check that golang.org/x/crypto is in your go.mod.
help me fix my Go import errors in internal/store/user.go
Build the auth handler and test registration
The auth handler will decode incoming JSON, call the user store, and sign a JWT containing the user's ID. You will create three functions: Register, Login, and a private generateToken helper.
- Create the file internal/handler/auth.go and add the package header, imports, struct, and constructor:
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"taskmanager/internal/model"
"taskmanager/internal/store"
)
// AuthHandler holds dependencies for auth endpoints
type AuthHandler struct {
userStore *store.UserStore
jwtSecret string
}
func NewAuthHandler(userStore *store.UserStore, jwtSecret string) *AuthHandler {
return &AuthHandler{userStore: userStore, jwtSecret: jwtSecret}
}
What does this code do?
AuthHandler receives the UserStore and a jwtSecret string. The secret is used to sign tokens so only your server can produce valid ones.
- Below the constructor, add the Register handler:
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req model.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Email == "" || req.Password == "" {
http.Error(w, `{"error":"email and password are required"}`, http.StatusBadRequest)
return
}
user, err := h.userStore.Create(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, `{"error":"could not create user"}`, http.StatusConflict)
return
}
token, err := h.generateToken(user.ID)
if err != nil {
http.Error(w, `{"error":"could not generate token"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(model.AuthResponse{Token: token, User: *user})
}
What does this code do?
Register decodes the email and password from the request body, validates that both are present, creates the user (which hashes the password internally), and returns a signed JWT so the client is immediately authenticated.
- Below Register, add the Login handler:
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req model.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
user, err := h.userStore.FindByEmail(r.Context(), req.Email)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
token, err := h.generateToken(user.ID)
if err != nil {
http.Error(w, `{"error":"could not generate token"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(model.AuthResponse{Token: token, User: *user})
}
What does this code do?
Login looks up the user by email, then compares the stored bcrypt hash against the plaintext password. If either step fails, it returns a generic "invalid credentials" error to avoid leaking whether an email exists.
- Finally, add the private generateToken helper at the bottom of the file:
func (h *AuthHandler) generateToken(userID string) (string, error) {
claims := jwt.RegisteredClaims{
Subject: userID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(h.jwtSecret))
}
What does this code do?
This creates a JWT with the user's ID in the Subject claim. The token expires after 24 hours and is signed with HS256 using the server's secret key.
- Save internal/handler/auth.go.
Now wire the auth handler into your router so you can test registration. You need to update cmd/api/main.go.
- In cmd/api/main.go, find this line in the import block:
"taskmanager/internal/store"
- Add this import directly above it so the import group looks like this:
"taskmanager/internal/handler"
appMiddleware "taskmanager/internal/middleware"
"taskmanager/internal/store"
- Find this line near the top of main():
ctx := context.Background()
- Add the JWT secret setup directly above it so it reads:
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "dev-secret-change-in-production"
}
ctx := context.Background()
- Find this line:
taskStore := store.NewTaskStore(pool)
taskHandler := handler.NewTaskHandler(taskStore)
- Replace it with these lines that also create the user store and auth handler:
taskStore := store.NewTaskStore(pool)
userStore := store.NewUserStore(pool)
authHandler := handler.NewAuthHandler(userStore, jwtSecret)
taskHandler := handler.NewTaskHandler(taskStore)
- Find the r.Route("/api" block and add the auth route group directly above it:
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
})
- Save cmd/api/main.go.
Before you run this, what do you think will happen when you POST to /auth/register with an email and password?
- Start the server by running:
go run cmd/api/main.go
- In a separate terminal, test registration by running:
curl -X POST http://localhost:8080/auth/register -d '{"email":"test@example.com","password":"secret123"}' -H "Content-Type: application/json"
You should see a JSON response containing a token field (a long encoded string) and a user object with your email and a generated UUID.
Seeing a connection error or compilation failure?
If the build fails with an unused import for appMiddleware, that is expected at this point. Remove the appMiddleware "taskmanager/internal/middleware" import line for now and add it back in the next substep when you create the middleware package.
If curl returns a connection refused error, make sure your Docker Postgres container is still running with docker ps.
help me debug why my auth register endpoint is not working
Create the auth middleware and protect routes
Registration works, but the task endpoints are still wide open. You need middleware that intercepts every request to /api, extracts the Bearer token, validates it, and puts the user ID into the request context.
- Create the file internal/middleware/auth.go and add the package header, context key, and the RequireAuth function:
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const userIDKey contextKey = "userID"
func RequireAuth(jwtSecret string) func(http.Handler) http.Handler {
return func(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, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
What does this code do?
RequireAuth returns a middleware function matching Chi's func(http.Handler) http.Handler signature. The first check rejects requests that have no Authorization header at all.
The contextKey type prevents collisions with other packages that might also store values in context.
- Continue the RequireAuth function body by adding the token parsing, validation, and context injection below:
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
subject, err := token.Claims.GetSubject()
if err != nil || subject == "" {
http.Error(w, `{"error":"invalid token claims"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
What does this code do?
The middleware strips the "Bearer " prefix, parses the JWT with the same secret used to sign it, and verifies the signing method is HMAC. If everything checks out, it extracts the Subject claim (the user ID) and injects it into the request context for downstream handlers.
- Below the RequireAuth function, add the GetUserID helper that handlers use to retrieve the authenticated user:
func GetUserID(ctx context.Context) string {
userID, _ := ctx.Value(userIDKey).(string)
return userID
}
What does this code do?
GetUserID is a convenience function. Handlers call it instead of doing the type assertion themselves, keeping the context key unexported and the code DRY.
- Save internal/middleware/auth.go.
Now apply the middleware to the /api route group and ensure the appMiddleware import is present in main.go.
- In cmd/api/main.go, find the r.Route("/api" block and add the middleware call as the first line inside it:
r.Route("/api", func(r chi.Router) {
r.Use(appMiddleware.RequireAuth(jwtSecret))
r.Get("/tasks", taskHandler.List)
r.Post("/tasks", taskHandler.Create)
r.Put("/tasks/{id}", taskHandler.Update)
r.Delete("/tasks/{id}", taskHandler.Delete)
})
- Make sure the import for appMiddleware "taskmanager/internal/middleware" is present in your import block (you may have removed it earlier).
- Save cmd/api/main.go.
- Stop the running server with Ctrl+C and restart it:
go run cmd/api/main.go
What do you think will happen if you hit GET /api/tasks without providing a token?
- Test an unauthenticated request by running:
curl http://localhost:8080/api/tasks
You should see {"error":"missing authorization header"} with a 401 status. The middleware is blocking unauthenticated access.
Still getting task data back without a token?
Make sure you added the r.Use(appMiddleware.RequireAuth(jwtSecret)) line inside the /api route group, not outside it. Also confirm you restarted the server after saving.
help me debug why my auth middleware is not blocking unauthenticated requests
Connect task handlers to the authenticated user
The task handlers still use a hardcoded fake user ID. You need to swap that for the real authenticated user from context.
- Open internal/handler/task.go and update the import block to include the middleware package:
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
appMiddleware "taskmanager/internal/middleware"
"taskmanager/internal/model"
"taskmanager/internal/store"
)
- In each handler function (List, Create, Update, Delete), replace any hardcoded user ID with this line at the top of the function:
userID := appMiddleware.GetUserID(r.Context())
What does this change?
Every task operation now uses the authenticated user's ID from the JWT token. When you list tasks, only that user's tasks come back. When you create a task, it belongs to the logged-in user.
- Save internal/handler/task.go.
- Stop and restart the server:
go run cmd/api/main.go
Now test the full flow. What do you expect when you register and then use the token to create a task?
- Register a fresh user (the previous test user may conflict) by running:
curl -X POST http://localhost:8080/auth/register -d '{"email":"alice@example.com","password":"pass1234"}' -H "Content-Type: application/json"
- Copy the token value from the response. Use it to create a task by running:
curl -X POST http://localhost:8080/api/tasks -d '{"title":"Deploy to production","description":"Ship v1"}' -H "Content-Type: application/json" -H "Authorization: Bearer your-token-here"
- List your tasks to confirm it was stored:
curl http://localhost:8080/api/tasks -H "Authorization: Bearer your-token-here"
You should see a JSON array containing the task you just created, with the user_id matching the authenticated user's UUID.
Getting a 401 when using your token?
Make sure you copied the full token string without any trailing whitespace. The token starts with eyJ and contains two dots separating three base64 sections.
help me debug why my JWT token is being rejected by the middleware
✔️ Awesome, I've got everything!
Great. Double check you have saved all four new files and your updated main.go.
ⓧ I'd like to double check the full code
Scroll up and confirm each code block is in place. Your project should now contain these files from this step:
- internal/model/user.go with User, RegisterRequest, LoginRequest, and AuthResponse structs.
- internal/store/user.go with UserStore, NewUserStore, Create, and FindByEmail.
- internal/handler/auth.go with AuthHandler, Register, Login, and generateToken.
- internal/middleware/auth.go with RequireAuth and GetUserID.
- internal/handler/task.go updated to use appMiddleware.GetUserID(r.Context()) in every handler.
- cmd/api/main.go updated with JWT_SECRET env var, UserStore, AuthHandler, /auth routes, and RequireAuth middleware on /api.
Your API now has real identity. Only registered, authenticated users can access the task endpoints, and each user's tasks are invisible to everyone else. Next, you will add production middleware and prove task isolation between multiple users.
Wire Up Middleware and Test the Full Flow
Your API now requires authentication to access tasks. But if something goes wrong in production, you have no visibility into what happened. You cannot debug what you cannot see.
Production APIs need more than auth. In this step, you will add Chi's built-in middleware for request logging, panic recovery, and request IDs. Then you will prove the entire end-to-end flow works by registering two users, creating tasks as each, and confirming complete data isolation.
In this step, get ready to:
- Add production middleware (logging, panic recovery, request IDs) to the router.
- Run the full registration and task creation flow with curl for two separate users.
- Verify task isolation between users and confirm unauthorized access is rejected.
Add production middleware to the router
Chi ships with a set of built-in middleware that every production Go API needs. You will wire up three of them globally so they run on every request that hits your server.
- In cmd/api/main.go, find this import line:
"github.com/go-chi/chi/v5"
- Add the middleware import directly below it so both lines sit together:
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- Now scroll down to find this line:
r := chi.NewRouter()
- Add the following three lines directly below it, before the /health route:
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
What does each middleware do?
- middleware.RequestID generates a unique ID for every incoming request and injects it into the request context. This lets you trace a single request across all your log output.
- middleware.Logger prints a structured log line for each request showing the HTTP method, path, response status, and how long it took.
- middleware.Recoverer catches any panic inside your handler code and returns a 500 response instead of crashing the entire server process.
- Save cmd/api/main.go.
- Stop your running server with Ctrl+C, then restart it by running:
go run cmd/api/main.go
- In a separate terminal, hit the health endpoint by running:
curl http://localhost:8080/health
Look back at the terminal where your server is running. You should see a log line showing the HTTP method, the /health path, a 200 status code, and the response duration.
Not seeing any log output?
- Make sure you added the three r.Use(...) lines before your route definitions, not after them. Middleware must be registered before routes to take effect.
- Check that your import includes "github.com/go-chi/chi/v5/middleware" (not just "github.com/go-chi/chi/v5").
- If you see a compile error about middleware being redeclared, note that your project already imports appMiddleware "taskmanager/internal/middleware". The Chi middleware package uses the plain name middleware which is separate from your appMiddleware alias.
Why isn't my Chi middleware logging requests?
✔️ Awesome, I've got everything!
Great. Double check you have saved cmd/api/main.go and your server is running with the new middleware active.
ⓧ I'd like to double check the full code
Confirm these two changes are present in cmd/api/main.go:
- The import block includes "github.com/go-chi/chi/v5/middleware" on the line directly below "github.com/go-chi/chi/v5".
- The three lines r.Use(middleware.RequestID), r.Use(middleware.Logger), and r.Use(middleware.Recoverer) appear directly after r := chi.NewRouter() and before the /health route.
Test the full authentication and task flow
Now you will prove the entire system works end-to-end by registering two users, creating tasks as each, and confirming their data is completely isolated.
- Register User A (Alice) by running:
curl -X POST http://localhost:8080/auth/register -d '{"email":"alice@example.com","password":"secret123"}' -H "Content-Type: application/json"
You should see a JSON response containing a token field and a user object with Alice's email. Copy the value of the token field (the long string between the quotes). You will use it in the next commands.
- Create a task as Alice by running (replace your-token-here with Alice's actual token):
curl -X POST http://localhost:8080/api/tasks -d '{"title":"Deploy to production","description":"Ship the v1 release"}' -H "Content-Type: application/json" -H "Authorization: Bearer your-token-here"
You should see a JSON response with the new task including an id, status set to pending, and Alice's user_id.
- Register User B (Bob) by running:
curl -X POST http://localhost:8080/auth/register -d '{"email":"bob@example.com","password":"secret456"}' -H "Content-Type: application/json"
Copy Bob's token from the response.
- Create a task as Bob by running (replace your-token-here with Bob's actual token):
curl -X POST http://localhost:8080/api/tasks -d '{"title":"Write unit tests","description":"Cover the auth handlers"}' -H "Content-Type: application/json" -H "Authorization: Bearer your-token-here"
Bob's task is now stored in PostgreSQL with his user_id. Now for the real test.
Before you run the next command, think: when you list tasks as Alice, will you see Bob's task too, or only Alice's?
- List tasks as Alice by running (use Alice's token):
curl http://localhost:8080/api/tasks -H "Authorization: Bearer your-token-here"
You should see a JSON array containing only Alice's task ("Deploy to production"). Bob's task does not appear.
- Now list tasks as Bob by running (use Bob's token):
curl http://localhost:8080/api/tasks -H "Authorization: Bearer your-token-here"
You should see only Bob's task ("Write unit tests"). Each user sees exclusively their own data.
Why does task isolation work?
Your RequireAuth middleware extracts the user ID from the JWT token and stores it in the request context. Every task handler calls appMiddleware.GetUserID(r.Context()) to read that ID.
The SQL query in ListByUser includes WHERE user_id = $1, so the database only returns rows belonging to that specific user. No application-level filtering needed.
Verify unauthorized access is rejected
The middleware should reject any request to the /api route group that lacks a valid token. What do you think will happen if you hit the endpoint with no Authorization header at all?
- Try listing tasks with no token by running:
curl http://localhost:8080/api/tasks
You should see a 401 response with the message {"error":"missing authorization header"}.
- Try with a malformed token by running:
curl http://localhost:8080/api/tasks -H "Authorization: Bearer this-is-not-a-real-token"
You should see a 401 response with the message {"error":"invalid or expired token"}. Your auth guard correctly rejects invalid credentials.
Getting a different error?
- If you see a 404 instead of 401, make sure you are hitting /api/tasks (not /tasks).
- If you see a 500, check that your JWT_SECRET environment variable matches between when you registered the user and when you restarted the server. If you never set it, the default dev-secret-change-in-production is used both times, so tokens should still validate.
Help me debug why my JWT middleware is returning unexpected errors.
Check your server logs
Look at the terminal running your server. Every request you made appears as a structured log line showing the method, path, status code, and duration. The successful requests show 200 or 201, while the rejected requests show 401. This is exactly the visibility you need to debug issues in production.
Secret mission
Add Task Filtering and Search
Real APIs never return all records unfiltered. Your code already has a powerful pattern hiding in plain sight. Can you prove it works by testing every filter combination?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing cloud costs.
Resources you used:
- Docker container taskmanager-db running PostgreSQL 17 on port 5432.
- Project folder taskmanager on your Desktop containing all Go source files and dependencies.
- Go module cache storing downloaded dependencies (Chi, pgx, golang-jwt, x/crypto).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend your API soon.
- Your taskmanager-db Docker container stays running and your database data persists.
- Your taskmanager project folder remains on your Desktop.
- The Go module cache stays on your machine (shared across all Go projects).
Pause - I'll come back to this later
Shut down the Docker container to free up memory and port 5432. Your project files stay on disk.
- Stop the running Go server with Ctrl+C in the terminal if it is still running.
- Stop the PostgreSQL container by running:
docker stop taskmanager-db
When you are ready to resume, start it again with:
docker start taskmanager-db
Your database data persists inside the stopped container.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop the running Go server with Ctrl+C in the terminal if it is still running.
- Stop and remove the PostgreSQL container by running:
docker stop taskmanager-db
docker rm taskmanager-db
- Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf taskmanager
On Windows:
cd ~\Desktop
rmdir /s /q taskmanager
- Optionally, clean the Go module cache for this project by running:
go clean -modcache
What does go clean -modcache do?
This removes all cached Go module downloads from your system. It frees disk space but means your next Go project will re-download its dependencies. Skip this if you plan to keep working with Go.
Nice Work!
Nice Work!
You just built a production-structured Go REST API from scratch. Your task manager handles user registration, login, and full CRUD operations with real PostgreSQL persistence and JWT-protected routes.
You've learned how to:
- Build a complete REST API in Go using the Chi router with standard net/http handler signatures, serving task CRUD endpoints that return JSON responses.
- Run PostgreSQL in Docker and connect with pgx/v5 for persistent storage, using parameterized queries to prevent SQL injection.
- Implement JWT authentication middleware with bcrypt password hashing that protects routes and propagates user identity through Go's request context.
- Secret Mission: Added dynamic query filtering with safe parameterized SQL for status and title search.
Ready to quiz yourself?