Right now, the bookmarkd API has a real database behind it, but no idea who is asking. Anyone who can reach /v1/bookmarks can list, create, or delete every bookmark in the system, because there is no concept of a user yet. This part adds registration, login, and JWT-based authentication, and scopes every bookmark to the account that created it.
This continues directly from Connect Go to PostgreSQL with pgx and sqlc on Ubuntu, where the bookmarks table and a PostgresStore implementation went in. If you already worked through Password Hashing in Golang with Bcrypt and Argon2, the argon2id implementation here will look familiar; this tutorial reuses that same pattern rather than reinventing it.
This tutorial is for developers who have bookmarkd running against PostgreSQL from Part 2 and want to add a real, if intentionally minimal, authentication layer: passwords hashed correctly, tokens issued and verified correctly, and routes actually protected. By the end, creating and viewing bookmarks will require a valid account, and each user will only ever see their own.
Conceptual Overview
JSON Web Tokens (JWT) are a compact, signed way to carry claims (small pieces of data, like a user ID and an expiry time) between a client and a server without the server needing to look anything up for every request. A JWT has three parts separated by dots: a header, a payload, and a signature. The server signs the payload with a secret key; anyone can read the payload, but only someone holding the secret can produce a signature that verifies, which is what makes the token trustworthy without a database lookup on every request.
This tutorial issues two tokens at login. An access token is short-lived (15 minutes) and sent with every request in an Authorization: Bearer <token> header; if it leaks, the damage window is small. A refresh token is long-lived (7 days) and used only to obtain a new access token once the old one expires, without forcing the user to log in again. Refresh tokens in this tutorial are stored in the database as a hash, not the raw token, which means a leaked database dump cannot be used to mint new sessions, and a specific refresh token can be revoked by deleting its row.
A Fiber middleware is just a handler that runs before the route handler and can either continue the chain by calling c.Next() or stop it by returning early, typically with an error. The authentication middleware in this tutorial reads the Authorization header, verifies the JWT, and if valid, stores the authenticated user’s ID where the rest of the request can read it, using Fiber’s c.Locals().
Prerequisites
Before starting, make sure you have:
- The
bookmarkdproject from Part 2, running against PostgreSQL ondb-01at10.20.0.52. sudoaccess onapi-01at10.20.0.51.- Go 1.25 and the
sqlcandgooseCLIs installed (from Part 2).
Step 1: Add a Users Table and a user_id Column
Create a new migration:
cd ~/bookmarkd
goose -dir db/migrations create add_users_and_ownership sql
Fill in the generated file:
-- +goose Up
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE bookmarks ADD COLUMN user_id uuid REFERENCES users (id) ON DELETE CASCADE;
CREATE INDEX idx_bookmarks_user_id ON bookmarks (user_id);
-- +goose Down
ALTER TABLE bookmarks DROP COLUMN user_id;
DROP TABLE refresh_tokens;
DROP TABLE users;
Apply it:
export DATABASE_URL="postgresql://bookmarkd:[email protected]:5432/bookmarkd?sslmode=disable"
goose -dir db/migrations postgres "$DATABASE_URL" up
Existing bookmarks created in Part 2 now have a NULL user_id. For a lab environment, either delete them or assign them to whichever test user you create in the next step; either way, bookmarks.user_id should stop accepting new NULL values once real accounts exist, which you can enforce with a follow-up ALTER TABLE bookmarks ALTER COLUMN user_id SET NOT NULL after backfilling.
Step 2: Hash Passwords with argon2id
Install the crypto package:
go get golang.org/x/crypto/argon2
Create internal/auth/password.go, following the same PHC-string approach as the linked bcrypt and argon2 article:
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
var ErrInvalidHash = errors.New("invalid password hash format")
type params struct {
memory uint32
time uint32
threads uint8
keyLen uint32
}
var defaultParams = params{memory: 64 * 1024, time: 3, threads: 2, keyLen: 32}
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
p := defaultParams
key := argon2.IDKey([]byte(password), salt, p.time, p.memory, p.threads, p.keyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Key := base64.RawStdEncoding.EncodeToString(key)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, p.memory, p.time, p.threads, b64Salt, b64Key), nil
}
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 {
return false, ErrInvalidHash
}
var p params
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
key, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
p.keyLen = uint32(len(key))
candidate := argon2.IDKey([]byte(password), salt, p.time, p.memory, p.threads, p.keyLen)
return subtle.ConstantTimeCompare(candidate, key) == 1, nil
}
subtle.ConstantTimeCompare matters here for the same reason it matters anywhere passwords are compared: a naive bytes.Equal returns as soon as it finds a mismatched byte, and the tiny timing difference between failing on the first byte versus the last byte is, in principle, measurable by an attacker over enough attempts.
Step 3: Issue and Verify JWTs
Add the JWT library:
go get github.com/golang-jwt/jwt/v5
Create internal/auth/token.go:
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("invalid or expired token")
type Claims struct {
UserID string `json:"sub"`
jwt.RegisteredClaims
}
func GenerateAccessToken(secret []byte, userID string) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ParseAccessToken(secret []byte, raw string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
The check on t.Method inside ParseAccessToken is not optional. A well-known attack against JWT libraries involves an attacker sending a token with alg set to none or switched to a different algorithm than the server expects, hoping the verification code trusts whatever algorithm the token claims to use. Explicitly requiring *jwt.SigningMethodHMAC closes that off; the server decides the algorithm, never the token itself.
Refresh tokens in this tutorial are simpler: an opaque, randomly generated string, stored in the refresh_tokens table as a SHA-256 hash so the raw value only ever exists in the client’s hands and briefly in server memory.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
)
func GenerateRefreshToken() (raw string, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = hex.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(sum[:])
return raw, hash, nil
}
Step 4: Registration and Login Handlers
Add the queries to db/queries/users.sql, then regenerate with sqlc generate:
-- name: CreateUser :one
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, created_at;
-- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at
FROM users
WHERE email = $1;
-- name: CreateRefreshToken :exec
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3);
Create internal/handler/auth.go:
package handler
import (
"errors"
"os"
"time"
"github.com/gofiber/fiber/v3"
"github.com/jackc/pgx/v5"
"github.com/facsiaginsa/bookmarkd/internal/auth"
"github.com/facsiaginsa/bookmarkd/internal/store/sqlcgen"
)
type AuthHandler struct {
Queries *sqlcgen.Queries
}
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) Register(c fiber.Ctx) error {
var in credentials
if err := c.Bind().Body(&in); err != nil || in.Email == "" || in.Password == "" {
return fiber.NewError(fiber.StatusBadRequest, "email and password are required")
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to hash password")
}
user, err := h.Queries.CreateUser(c.Context(), sqlcgen.CreateUserParams{
Email: in.Email,
PasswordHash: hash,
})
if err != nil {
return fiber.NewError(fiber.StatusConflict, "email already registered")
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": user.ID, "email": user.Email})
}
func (h *AuthHandler) Login(c fiber.Ctx) error {
var in credentials
if err := c.Bind().Body(&in); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid request body")
}
user, err := h.Queries.GetUserByEmail(c.Context(), in.Email)
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(fiber.StatusUnauthorized, "invalid email or password")
} else if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "login failed")
}
ok, err := auth.VerifyPassword(in.Password, user.PasswordHash)
if err != nil || !ok {
return fiber.NewError(fiber.StatusUnauthorized, "invalid email or password")
}
secret := []byte(os.Getenv("JWT_SECRET"))
accessToken, err := auth.GenerateAccessToken(secret, user.ID.String())
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
rawRefresh, hashedRefresh, err := auth.GenerateRefreshToken()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
err = h.Queries.CreateRefreshToken(c.Context(), sqlcgen.CreateRefreshTokenParams{
UserID: user.ID,
TokenHash: hashedRefresh,
ExpiresAt: pgxTimestamp(time.Now().Add(7 * 24 * time.Hour)),
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to store refresh token")
}
return c.JSON(fiber.Map{
"access_token": accessToken,
"refresh_token": rawRefresh,
"expires_in": 900,
})
}
pgxTimestamp is a small helper that wraps a time.Time into pgx’s pgtype.Timestamptz; omitted here for brevity but straightforward, following the same pattern as parseUUID from Part 2.
Step 5: The Authentication Middleware
Create internal/handler/middleware.go:
package handler
import (
"os"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/facsiaginsa/bookmarkd/internal/auth"
)
func Authenticate(c fiber.Ctx) error {
header := c.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return fiber.NewError(fiber.StatusUnauthorized, "missing bearer token")
}
raw := strings.TrimPrefix(header, "Bearer ")
secret := []byte(os.Getenv("JWT_SECRET"))
claims, err := auth.ParseAccessToken(secret, raw)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "invalid or expired token")
}
c.Locals("userID", claims.UserID)
return c.Next()
}
Wire the routes in cmd/api/main.go, registering /v1/auth/register and /v1/auth/login openly, and applying handler.Authenticate only to the bookmarks group:
authHandler := &handler.AuthHandler{Queries: queries}
authGroup := v1.Group("/auth")
authGroup.Post("/register", authHandler.Register)
authGroup.Post("/login", authHandler.Login)
bookmarks := v1.Group("/bookmarks", handler.Authenticate)
bookmarks.Get("/", bookmarkHandler.List)
bookmarks.Get("/:id", bookmarkHandler.Get)
bookmarks.Post("/", bookmarkHandler.Create)
bookmarks.Delete("/:id", bookmarkHandler.Delete)
Passing handler.Authenticate as a second argument to Group applies it to every route registered under that group, so nothing under /v1/bookmarks is reachable without a valid token.
Step 6: Scope Bookmarks to the Authenticated User
The store.Store interface from Part 1 needs a userID on every method, and bookmarks.user_id needs to be part of every query. Update the interface:
type Store interface {
List(ctx context.Context, userID string) ([]Bookmark, error)
Get(ctx context.Context, userID, id string) (Bookmark, error)
Create(ctx context.Context, userID string, b Bookmark) (Bookmark, error)
Delete(ctx context.Context, userID, id string) error
}
Update the sqlc queries in db/queries/bookmarks.sql to filter and insert by user_id, for example:
-- name: ListBookmarks :many
SELECT id, title, url, created_at
FROM bookmarks
WHERE user_id = $1
ORDER BY created_at DESC;
Apply the same WHERE user_id = $1 pattern to GetBookmark and DeleteBookmark, and add user_id as a column to CreateBookmark’s INSERT. In the handlers, read the ID that Authenticate stored and pass it through:
func (h *BookmarkHandler) List(c fiber.Ctx) error {
userID := c.Locals("userID").(string)
bookmarks, err := h.Store.List(c.Context(), userID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to list bookmarks")
}
return c.JSON(bookmarks)
}
Apply the same one-line change, reading userID from c.Locals and passing it to the store call, to Get, Create, and Delete. Because a request can never reach these handlers without passing through Authenticate first, c.Locals("userID") is guaranteed to be set.
Common Mistakes and Troubleshooting
Every request returns 401 even with a token attached. Check the header is exactly Authorization: Bearer <token>, with a single space after Bearer and no quotes around the token. Also confirm JWT_SECRET is set to the same value the token was signed with; a restarted process with a different or unset secret invalidates every previously issued token.
Login succeeds but VerifyPassword always returns false. This usually means the password was hashed with different argon2 parameters than the ones used to verify it, or the stored hash was truncated by a database column that is too short. text columns in PostgreSQL have no length limit, so if you changed the column type, check that first.
Refresh tokens accumulate forever in the database. Nothing in this tutorial deletes expired rows automatically. In production, run a periodic job (a cron entry or a pg_cron job) that deletes rows from refresh_tokens where expires_at has passed.
A bookmark created before this part is invisible to every user. Its user_id is NULL, and every query now filters by a real user ID, which NULL never matches. This is expected; either backfill those rows to a real user or delete them, as mentioned in Step 1.
Best Practices
- Never log raw passwords or tokens, even at debug level. A log line containing a password or an unexpired JWT is functionally the same as storing the credential in plain text.
- Keep access tokens short-lived. Fifteen minutes limits how long a stolen token remains useful, and the refresh flow keeps that invisible to the user.
- Store only a hash of the refresh token, never the raw value, exactly as this tutorial hashes it with SHA-256 before writing it to
refresh_tokens. A stolen database dump should not be enough to mint new sessions. - Reject the
nonealgorithm and enforce the expected signing method explicitly, asParseAccessTokendoes, rather than trusting whatever algorithm a token claims to use. - Rotate
JWT_SECRETdeliberately, not accidentally. Since every previously issued access token becomes invalid the moment the secret changes, treat it as a real secret with a defined rotation process, not an environment variable that gets regenerated by accident on every deploy.
Conclusion
The bookmarkd API now has real accounts: passwords hashed with argon2id, short-lived JWT access tokens verified by a dedicated middleware, refresh tokens stored as hashes for revocability, and every bookmark scoped to the user who owns it. None of the routing or storage groundwork from Parts 1 and 2 had to be thrown away; the interface change to store.Store and the addition of one middleware were enough.
The service still has no visibility into what it is doing once deployed: no structured logs, no metrics, and no graceful way to shut down without dropping in-flight requests. Part 4 covers all three.