← Back to the blog

JWT auth in Go and Postgres, from scratch

A complete walkthrough: user registration, password hashing, JWT signing, protected routes, and refresh tokens — no auth library required.

A JWT authentication system in Go has four moving parts: user storage in Postgres, password hashing with bcrypt, JWT signing with a secret key, and middleware that validates the token on every protected request. This post builds all four from scratch — no auth library, no magic.

Quick answerTo add JWT auth to a Go + Postgres backend: hash passwords with bcrypt, sign tokens with golang-jwt/jwt, store only the user row (not the token), validate the token in middleware using the same secret. That's the whole system.

How JWT works

A JSON Web Token is a base64-encoded string with three parts: a header (algorithm), a payload (claims), and a signature. The server signs it with a secret key on login. On each subsequent request, the client sends the token; the server verifies the signature and trusts the claims without hitting the database.

That statelessness is both the advantage and the limitation. You can scale horizontally without shared session state. But you can't invalidate a token until it expires — so keep expiry short (15 minutes) and use refresh tokens for longer sessions.

Project structure

// Flat by function, not by layer.
auth/
  handler.go     // HTTP handlers: register, login, refresh
  service.go     // business logic
  token.go       // JWT sign/verify
  middleware.go  // RequireAuth handler wrapper
user/
  store.go       // Postgres queries via sqlc
  model.go       // User struct

Keep auth logic isolated. The handler layer speaks HTTP. The service layer speaks domain. The store layer speaks SQL. None of the three reach into each other's layer.

Storing users in Postgres

The users table is intentionally minimal at first. Add columns when you have a reason, not before.

CREATE TABLE users (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  email       TEXT        NOT NULL UNIQUE,
  password    TEXT        NOT NULL,           -- bcrypt hash
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

The password column stores a bcrypt hash — never plaintext, never a raw SHA256. Use bcrypt.GenerateFromPassword at cost 12 on registration, bcrypt.CompareHashAndPassword on login.

func (s *Service) Register(ctx context.Context, email, password string) (*user.User, error) {
  hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
  if err != nil {
    return nil, fmt.Errorf("hash password: %w", err)
  }
  return s.store.CreateUser(ctx, email, string(hash))
}

Generating and signing tokens

Use github.com/golang-jwt/jwt/v5 — the maintained fork of the original dgrijalva library. Sign with HS256 for a single-server deployment; switch to RS256 if multiple services need to verify tokens independently.

type Claims struct {
  UserID string `json:"sub"`
  jwt.RegisteredClaims
}

func GenerateAccessToken(userID, secret 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([]byte(secret))
}

Protected routes

Middleware extracts the token from the Authorization header, verifies it, and injects the user ID into the request context. Every protected handler reads the user ID from context — never from the request body.

func RequireAuth(secret string) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
      claims, err := ParseToken(raw, secret)
      if err != nil {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
      }
      ctx := context.WithValue(r.Context(), ctxKeyUserID, claims.UserID)
      next.ServeHTTP(w, r.WithContext(ctx))
    })
  }
}

Refresh tokens

A refresh token is a long-lived opaque token (a random UUID) stored in Postgres with an expiry. When the access token expires, the client sends the refresh token to POST /auth/refresh; the server validates it against the database and issues a new access token.

CREATE TABLE refresh_tokens (
  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token      TEXT        NOT NULL UNIQUE,
  expires_at TIMESTAMPTZ NOT NULL,
  revoked_at TIMESTAMPTZ
);

On logout or password change, set revoked_at = now(). The refresh endpoint checks revoked_at IS NULL AND expires_at > now() before issuing a new access token.

Common mistakes

  • Long access token expiry. Fifteen minutes is correct. One week is a session stored in the client with no revocation path.
  • Storing the JWT in localStorage. Readable by any script on the page. Use an HttpOnly cookie for the refresh token; keep the access token in memory.
  • Not checking alg on verify. Always pass the expected signing method explicitly — jwt.ParseWithClaims accepts a key function; reject tokens whose algorithm doesn't match.
  • Hitting the database on every request.The whole point of JWT is statelessness. If you're querying users on every request to validate the token, use sessions instead.

The full working example — with tests — is available on our open source page. If you want us to review or build your auth layer, we do that too.

★ ★ ★

End of article · Thanks for reading

Subscribe

More of this, once a month.