DEV Community

TechTz Academy
TechTz Academy

Posted on

I built a real Helpdesk API in Go — no framework, just the standard library

Most "build an API in Go" tutorials stop at a /hello route. This one goes all the way to something you could actually put behind a support desk: accounts, hashed passwords, JWT login, role permissions, filters, automated tests and a Docker image.

The whole thing uses net/http and database/sql from the standard library, plus exactly two dependencies: a SQLite driver and a JWT library. No Gin, no Echo, no ORM.

All the code is here: https://github.com/Abbysifuni/helpdesk-api

Here is what it does and the decisions that mattered, with the full video walkthrough at the end.

What we're building

A ticket system with two kinds of users:

Customers register, open tickets and comment on their own tickets.
Agents see every ticket, change status and priority, assign tickets to themselves and delete them.

Tickets have a status (open, in_progress, resolved, closed) and a priority (low, medium, high). Everything lives in a single SQLite file.

Routing without a framework

Go 1.22 added method and wildcard patterns to http.ServeMux, and that removed most of the reason people reached for a router library. The entire routing table:

func (a *App) routes() http.Handler {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /health", a.health)
    mux.HandleFunc("POST /api/register", a.register)
    mux.HandleFunc("POST /api/login", a.login)

    mux.Handle("GET /api/me", a.auth(a.me))
    mux.Handle("POST /api/tickets", a.auth(a.createTicket))
    mux.Handle("GET /api/tickets", a.auth(a.listTickets))
    mux.Handle("GET /api/tickets/{id}", a.auth(a.getTicket))
    mux.Handle("PATCH /api/tickets/{id}", a.auth(a.updateTicket))
    mux.Handle("DELETE /api/tickets/{id}", a.auth(a.deleteTicket))
    mux.Handle("POST /api/tickets/{id}/comments", a.auth(a.addComment))
    mux.Handle("GET /api/tickets/{id}/comments", a.auth(a.listComments))

    return logRequests(mux)
}
Enter fullscreen mode Exit fullscreen mode

r.PathValue("id") reads the wildcard. a.auth(...) is the middleware that turns a handler into an authenticated one — more on that below.

Handlers hang off an App struct instead of package-level globals:

type App struct {
    DB         *sql.DB
    Secret     []byte
    AgentEmail string
}
Enter fullscreen mode Exit fullscreen mode

That one change is what makes the tests easy later — you can build an App pointing at an in-memory database and the handlers neither know nor care.

Passwords: PBKDF2 from the standard library

Go 1.24 moved PBKDF2 into crypto/pbkdf2, so hashing passwords no longer needs golang.org/x/crypto. The stored format is Django-style, so the iteration count travels with the hash and you can raise it later without breaking old accounts:

const hashIterations = 600_000 // OWASP recommendation for PBKDF2-SHA256

// hashPassword returns "pbkdf2_sha256$iterations$salt$hash".
func hashPassword(password string) (string, error) {
    salt := make([]byte, 16)
    if _, err := rand.Read(salt); err != nil {
        return "", err
    }
    key, err := pbkdf2.Key(sha256.New, password, salt, hashIterations, 32)
    if err != nil {
        return "", err
    }
    b64 := base64.RawStdEncoding.EncodeToString
    return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s", hashIterations, b64(salt), b64(key)), nil
}
Enter fullscreen mode Exit fullscreen mode

Verification splits the stored string back apart and compares with subtle.ConstantTimeCompare, never ==. A plain string comparison leaks timing information about how many bytes matched.

One more small thing that matters: a wrong email and a wrong password return the same 401 with the same message. Different errors tell an attacker which emails are registered.

The auth middleware

func (a *App) auth(next authedHandler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tokenStr, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
        if !ok {
            writeError(w, http.StatusUnauthorized, "missing token")
            return
        }
        token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
            return a.Secret, nil
        }, jwt.WithValidMethods([]string{"HS256"}))
        if err != nil || !token.Valid {
            writeError(w, http.StatusUnauthorized, "invalid or expired token")
            return
        }
        sub, _ := token.Claims.GetSubject()
        id, _ := strconv.ParseInt(sub, 10, 64)
        u, err := a.getUser(id)
        if err != nil {
            writeError(w, http.StatusUnauthorized, "user not found")
            return
        }
        next(w, r, u)
    })
}
Enter fullscreen mode Exit fullscreen mode

Two details worth copying:

jwt.WithValidMethods([]string{"HS256"}) is not optional. Without it, an attacker can hand you a token with "alg": "none" and the library will happily accept it. Pin the algorithm.

The middleware takes an authedHandler — func(w, r, User) — instead of stuffing the user into the request context. The user then arrives as a typed argument that the compiler checks, rather than an any you have to assert at the top of every handler.

404, not 403

When a customer asks for someone else's ticket, the obvious answer is 403 Forbidden. That's the wrong answer. A 403 confirms the ticket exists. Loop over the IDs and you've mapped how many tickets the system holds and which ones are real.

So the lookup scopes by user first, and anything the caller can't see is simply not found:

// customers only ever match their own rows
if user.Role != "agent" {
    query += " AND user_id = ?"
    args = append(args, user.ID)
}
Enter fullscreen mode Exit fullscreen mode

403 is still the right status when a customer tries to do something they're not allowed to on a ticket they can legitimately see — closing is fine, reassigning is not.

Filters without SQL injection

GET /api/tickets?status=open&priority=high builds its WHERE clause dynamically, which is exactly where people start concatenating strings. Don't. Build the placeholders, collect the values:

query := "SELECT " + ticketCols + " FROM tickets WHERE 1=1"
args := []any{}

if s := r.URL.Query().Get("status"); s != "" {
    if !validStatus[s] {
        writeError(w, http.StatusBadRequest, "invalid status")
        return
    }
    query += " AND status = ?"
    args = append(args, s)
}
Enter fullscreen mode Exit fullscreen mode

The user's input never becomes SQL. It becomes an argument. And the validStatus map means a junk filter gets a clear 400 instead of silently returning nothing.

Tests that actually exercise the API

httptest plus an in-memory SQLite database gives you a real server, real HTTP, real SQL, and it runs in seconds:

func newTestServer(t *testing.T) (*httptest.Server, *App) {
    db, err := openDB(":memory:")
    if err != nil {
        t.Fatal(err)
    }
    app := &App{DB: db, Secret: []byte("test-secret"), AgentEmail: "agent@test.com"}
    srv := httptest.NewServer(app.routes())
    t.Cleanup(srv.Close)
    return srv, app
}
Enter fullscreen mode Exit fullscreen mode

One gotcha: with :memory:, each new connection gets its own empty database. Set db.SetMaxOpenConns(1) so the pool reuses a single connection and your tables don't vanish between queries.

The suite covers registration validation as a table-driven test, login and JWT, the full ticket lifecycle, and the permission rules from both sides — customer and agent.

Shipping it

A multi-stage Dockerfile builds with golang:1.24-bookworm and CGO_ENABLED=1 (the SQLite driver needs cgo), then copies the binary into debian:bookworm-slim, runs as a non-root user, and mounts /data as a volume so the database survives container restarts.

Config comes from environment variables — ADDR, DB_PATH, JWT_SECRET, AGENT_EMAIL — so nothing secret ends up in the image.

The full series

Eight parts, roughly ten minutes each, building the whole thing from an empty folder:

  1. Plan & project setup
  2. The database with SQLite
  3. Registering users safely
  4. Login with JWT
  5. Tickets: create & list
  6. Update, delete & comments
  7. Testing the API
  8. Docker, docs & deploy

Part 1 — Plan & Project Setup:

Watch the rest in order here:

Build a real helpdesk ticket API with Go, SQLite and JWT, step by step in 8 short parts: project setup, database, user accounts, login, tickets, comments, te...

favicon youtube.com

Full source, README and tests: https://github.com/Abbysifuni/helpdesk-api

If you're still getting comfortable with Go itself, the 30-day beginner course that leads into this one is here: https://www.youtube.com/playlist?list=PLed_bc2kQ9LM

Happy to answer questions about any of the choices above in the comments — especially the 404-instead-of-403 one, which surprises people.

Top comments (0)