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.
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 structKeep 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
HttpOnlycookie for the refresh token; keep the access token in memory. - Not checking
algon verify. Always pass the expected signing method explicitly —jwt.ParseWithClaimsaccepts 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