You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2025/08/06 07:00:00 UTC

(skywalking-banyandb) branch main updated: Enable Login with Username/Password in BanyanDB (#704)

This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


The following commit(s) were added to refs/heads/main by this push:
     new 42ec9df7 Enable Login with Username/Password in BanyanDB (#704)
42ec9df7 is described below

commit 42ec9df7457868926eb80157b36355d94fcd6bba
Author: OmCheeLin <19...@163.com>
AuthorDate: Wed Aug 6 14:59:55 2025 +0800

    Enable Login with Username/Password in BanyanDB (#704)
    
    * feat: support login username
    
    ---------
    
    Co-authored-by: 吴晟 Wu Sheng <wu...@foxmail.com>
    Co-authored-by: Gao Hongtao <ha...@gmail.com>
---
 CHANGES.md                                         |   1 +
 banyand/liaison/grpc/auth.go                       |  92 +++++++++
 banyand/liaison/grpc/server.go                     |  21 +++
 banyand/liaison/http/auth.go                       | 128 +++++++++++++
 banyand/liaison/http/server.go                     |  29 ++-
 banyand/liaison/pkg/auth/config.go                 | 105 +++++++++++
 bydbctl/internal/cmd/auth_test.go                  | 164 ++++++++++++++++
 bydbctl/internal/cmd/health_check.go               |  12 +-
 bydbctl/internal/cmd/rest.go                       |  23 +++
 bydbctl/internal/cmd/root.go                       |  33 +++-
 bydbctl/internal/cmd/testdata/.bydbctl.yaml        |   3 +
 bydbctl/internal/cmd/testdata/.bydbctl1.yaml       |   3 +
 bydbctl/internal/cmd/testdata/config.yaml          |   5 +
 docs/operation/security.md                         |  83 ++++++++
 go.mod                                             |   2 +-
 pkg/auth/util.go                                   |  31 +++
 pkg/cmdsetup/liaison.go                            |   2 +-
 pkg/cmdsetup/standalone.go                         |   2 +-
 pkg/config/config.go                               |   6 +-
 pkg/grpchelper/client.go                           |  13 +-
 pkg/test/helpers/grpc_health.go                    |  13 +-
 pkg/test/helpers/http_health.go                    |  14 +-
 pkg/test/setup/setup.go                            |  39 +++-
 test/cases/measure/data/data.go                    |  33 +++-
 test/integration/standalone/other/auth_test.go     | 210 +++++++++++++++++++++
 .../standalone/other/testdata/config.yaml          |   5 +
 26 files changed, 1042 insertions(+), 30 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 62653027..1e2de1ed 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -27,6 +27,7 @@ Release Notes.
 - Add chunked data sync to improve memory efficiency and performance during data transfer operations, supporting configurable chunk sizes, retry mechanisms, and out-of-order handling for both measure and stream services.
 - Implement comprehensive migration system for both measure and stream data with file-based approach and enhanced progress tracking
 - Improve TopN processing by adding "source" tag to track node-specific data, enhancing data handling across distributed nodes
+- Implement Login with Username/Password authentication in BanyanDB
 
 ### Bug Fixes
 
diff --git a/banyand/liaison/grpc/auth.go b/banyand/liaison/grpc/auth.go
new file mode 100644
index 00000000..52ad21a5
--- /dev/null
+++ b/banyand/liaison/grpc/auth.go
@@ -0,0 +1,92 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/metadata"
+	"google.golang.org/grpc/status"
+
+	"github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+)
+
+func authInterceptor(cfg *auth.Config) grpc.UnaryServerInterceptor {
+	return func(
+		ctx context.Context,
+		req interface{},
+		info *grpc.UnaryServerInfo,
+		handler grpc.UnaryHandler,
+	) (interface{}, error) {
+		if !cfg.Enabled {
+			return handler(ctx, req)
+		}
+		if info.FullMethod == "/grpc.health.v1.Health/Check" && !cfg.HealthAuthEnabled {
+			return handler(ctx, req)
+		}
+		if err := validateUser(ctx, cfg); err != nil {
+			return nil, err
+		}
+		return handler(ctx, req)
+	}
+}
+
+func authStreamInterceptor(cfg *auth.Config) grpc.StreamServerInterceptor {
+	return func(
+		srv interface{},
+		stream grpc.ServerStream,
+		info *grpc.StreamServerInfo,
+		handler grpc.StreamHandler,
+	) error {
+		if !cfg.Enabled {
+			return handler(srv, stream)
+		}
+		if info.FullMethod == "/grpc.health.v1.Health/Check" && !cfg.HealthAuthEnabled {
+			return handler(srv, stream)
+		}
+		if err := validateUser(stream.Context(), cfg); err != nil {
+			return err
+		}
+		return handler(srv, stream)
+	}
+}
+
+func validateUser(ctx context.Context, cfg *auth.Config) error {
+	md, ok := metadata.FromIncomingContext(ctx)
+	if !ok {
+		return status.Errorf(codes.Unauthenticated, "metadata is not provided")
+	}
+
+	usernames := md.Get("username")
+	passwords := md.Get("password")
+
+	if len(usernames) == 0 || len(passwords) == 0 {
+		return status.Errorf(codes.Unauthenticated, "Invalid credentials")
+	}
+
+	username := usernames[0]
+	password := passwords[0]
+
+	if !auth.CheckUsernameAndPassword(cfg, username, password) {
+		return status.Errorf(codes.Unauthenticated, "Invalid credentials")
+	}
+
+	return nil
+}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index f7fccd38..6a55dc65 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -40,6 +40,7 @@ import (
 	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
 	propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
 	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
 	"github.com/apache/skywalking-banyandb/banyand/metadata"
 	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
 	"github.com/apache/skywalking-banyandb/banyand/observability"
@@ -65,6 +66,7 @@ var (
 // Server defines the gRPC server.
 type Server interface {
 	run.Unit
+	GetAuthCfg() *auth.Config
 	GetPort() *uint32
 }
 
@@ -98,6 +100,8 @@ type server struct {
 	metrics                  *metrics
 	certFile                 string
 	keyFile                  string
+	authConfigFile           string
+	cfg                      *auth.Config
 	host                     string
 	addr                     string
 	accessLogRootPath        string
@@ -161,6 +165,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
 			schemaRegistry: schemaRegistry,
 		},
 		schemaRepo: schemaRegistry,
+		cfg:        auth.InitCfg(),
 	}
 	s.accessLogRecorders = []accessLogRecorder{streamSVC, measureSVC}
 
@@ -184,6 +189,11 @@ func (s *server) PreRun(_ context.Context) error {
 			return err
 		}
 	}
+	if s.authConfigFile != "" {
+		if err := auth.LoadConfig(s.cfg, s.authConfigFile); err != nil {
+			return err
+		}
+	}
 
 	if s.enableIngestionAccessLog {
 		for _, alr := range s.accessLogRecorders {
@@ -229,6 +239,11 @@ func (s *server) GetPort() *uint32 {
 	return &s.port
 }
 
+// GetAuthCfg returns auth cfg (for httpserver).
+func (s *server) GetAuthCfg() *auth.Config {
+	return s.cfg
+}
+
 func (s *server) FlagSet() *run.FlagSet {
 	fs := run.NewFlagSet("grpc")
 	s.maxRecvMsgSize = defaultRecvSize
@@ -236,6 +251,8 @@ func (s *server) FlagSet() *run.FlagSet {
 	fs.BoolVar(&s.tls, "tls", false, "connection uses TLS if true, else plain TCP")
 	fs.StringVar(&s.certFile, "cert-file", "", "the TLS cert file")
 	fs.StringVar(&s.keyFile, "key-file", "", "the TLS key file")
+	fs.StringVar(&s.authConfigFile, "auth-config-file", "", "Path to the authentication config file (YAML format)")
+	fs.BoolVar(&s.cfg.HealthAuthEnabled, "enable-health-auth", false, "enable authentication for health check")
 	fs.StringVar(&s.host, "grpc-host", "", "the host of banyand listens")
 	fs.Uint32Var(&s.port, "grpc-port", 17912, "the port of banyand listens")
 	fs.BoolVar(&s.enableIngestionAccessLog, "enable-ingestion-access-log", false, "enable ingestion access log")
@@ -297,6 +314,10 @@ func (s *server) Serve() run.StopNotify {
 		grpc_validator.UnaryServerInterceptor(),
 		recovery.UnaryServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),
 	}
+	if s.authConfigFile != "" {
+		streamChain = append(streamChain, authStreamInterceptor(s.cfg))
+		unaryChain = append(unaryChain, authInterceptor(s.cfg))
+	}
 
 	opts = append(opts, grpclib.MaxRecvMsgSize(int(s.maxRecvMsgSize)),
 		grpclib.ChainUnaryInterceptor(unaryChain...),
diff --git a/banyand/liaison/http/auth.go b/banyand/liaison/http/auth.go
new file mode 100644
index 00000000..716e73f4
--- /dev/null
+++ b/banyand/liaison/http/auth.go
@@ -0,0 +1,128 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package http
+
+import (
+	"context"
+	"encoding/base64"
+	"net/http"
+	"strings"
+
+	"github.com/pkg/errors"
+	"google.golang.org/grpc/metadata"
+
+	"github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+)
+
+func authMiddleware(cfg *auth.Config) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if isStaticPath(r.URL.Path) {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			if !cfg.Enabled {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			if r.URL.Path == "/api/healthz" && !cfg.HealthAuthEnabled {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			authHeader := r.Header.Get("Authorization")
+			if authHeader == "" {
+				w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
+				http.Error(w, "Authorization header is missing", http.StatusUnauthorized)
+				return
+			}
+
+			if !strings.HasPrefix(authHeader, "Basic ") {
+				http.Error(w, "Invalid authorization header format", http.StatusBadRequest)
+				return
+			}
+
+			encodedCredentials := strings.TrimPrefix(authHeader, "Basic ")
+
+			decodedBytes, err := base64.StdEncoding.DecodeString(encodedCredentials)
+			if err != nil {
+				http.Error(w, "Failed to decode authorization header", http.StatusBadRequest)
+				return
+			}
+
+			decodedCredentials := string(decodedBytes)
+
+			parts := strings.SplitN(decodedCredentials, ":", 2)
+			if len(parts) != 2 {
+				http.Error(w, "Invalid authorization header format", http.StatusBadRequest)
+				return
+			}
+
+			username := parts[0]
+			password := parts[1]
+			if !auth.CheckUsernameAndPassword(cfg, username, password) {
+				http.Error(w, `{"error": "invalid credentials"}`, http.StatusUnauthorized)
+				return
+			}
+
+			r.Header.Set("Grpc-Metadata-Username", username)
+			r.Header.Set("Grpc-Metadata-Password", password)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+var staticPaths = []string{
+	"/favicon.ico",
+	"/banyandb.ico",
+	"/assets/",
+	"index.html",
+}
+
+func isStaticPath(path string) bool {
+	for _, prefix := range staticPaths {
+		if strings.HasPrefix(path, prefix) {
+			return true
+		}
+	}
+	return false
+}
+
+func buildGRPCContextForHealthCheck(cfg *auth.Config, r *http.Request) (context.Context, error) {
+	ctx := r.Context()
+
+	if cfg.HealthAuthEnabled {
+		username := r.Header.Get("Grpc-Metadata-Username")
+		password := r.Header.Get("Grpc-Metadata-Password")
+
+		if username == "" || password == "" {
+			return nil, errors.New("missing authentication metadata")
+		}
+
+		md := metadata.New(map[string]string{
+			"username": username,
+			"password": password,
+		})
+
+		ctx = metadata.NewOutgoingContext(ctx, md)
+	}
+
+	return ctx, nil
+}
diff --git a/banyand/liaison/http/server.go b/banyand/liaison/http/server.go
index 24d84b3a..b0a88241 100644
--- a/banyand/liaison/http/server.go
+++ b/banyand/liaison/http/server.go
@@ -36,12 +36,14 @@ import (
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials"
 	"google.golang.org/grpc/credentials/insecure"
+	"google.golang.org/grpc/health/grpc_health_v1"
 
 	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
 	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
 	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
 	propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
 	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
 	"github.com/apache/skywalking-banyandb/pkg/healthcheck"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 	"github.com/apache/skywalking-banyandb/pkg/run"
@@ -58,9 +60,10 @@ var (
 )
 
 // NewServer return a http service.
-func NewServer() Server {
+func NewServer(cfg *auth.Config) Server {
 	return &server{
 		stopCh: make(chan struct{}),
+		cfg:    cfg,
 	}
 }
 
@@ -87,6 +90,7 @@ type server struct {
 	grpcAddr        string
 	keyFile         string
 	certFile        string
+	cfg             *auth.Config
 	grpcCert        string
 	grpcMu          sync.Mutex
 	port            uint32
@@ -338,7 +342,7 @@ func (p *server) initGRPCClient() error {
 	p.grpcClient.Store(client)
 
 	// Create gateway mux with health endpoint
-	p.gwMux = runtime.NewServeMux(runtime.WithHealthzEndpoint(p.grpcClient.Load()))
+	p.gwMux = runtime.NewServeMux()
 
 	// Register all service handlers
 	err = multierr.Combine(
@@ -364,6 +368,27 @@ func (p *server) initGRPCClient() error {
 	// This avoids the conflict when remounting to /api path
 	newMux := chi.NewRouter()
 
+	newMux.Use(authMiddleware(p.cfg))
+	newMux.Handle("/api/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx, err := buildGRPCContextForHealthCheck(p.cfg, r)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusUnauthorized)
+			return
+		}
+		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+		defer cancel()
+		resp, err := p.grpcClient.Load().Check(ctx, &grpc_health_v1.HealthCheckRequest{}) //nolint:contextcheck
+		if err != nil {
+			http.Error(w, "gRPC health check failed: "+err.Error(), http.StatusServiceUnavailable)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+		_, err = w.Write([]byte(resp.Status.String()))
+		if err != nil {
+			logger.Errorf("Failed to write health check response: %v", err)
+		}
+	}))
+
 	// Mount the gateway mux to the HTTP server
 	newMux.Mount("/api", http.StripPrefix("/api", p.gwMux))
 
diff --git a/banyand/liaison/pkg/auth/config.go b/banyand/liaison/pkg/auth/config.go
new file mode 100644
index 00000000..f595a93d
--- /dev/null
+++ b/banyand/liaison/pkg/auth/config.go
@@ -0,0 +1,105 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package auth provides configuration management and validation logic for authentication.
+package auth
+
+import (
+	"crypto/subtle"
+	"fmt"
+	"os"
+	"strings"
+
+	"sigs.k8s.io/yaml"
+)
+
+// Config AuthConfig.
+type Config struct {
+	Users             []User `yaml:"users"`
+	Enabled           bool   `yaml:"-"`
+	HealthAuthEnabled bool   `yaml:"-"`
+}
+
+// User details from config file.
+type User struct {
+	Username string `yaml:"username"`
+	Password string `yaml:"password"`
+}
+
+// InitCfg returns Config with default values.
+func InitCfg() *Config {
+	return &Config{
+		Enabled:           false,
+		HealthAuthEnabled: false,
+		Users:             []User{},
+	}
+}
+
+// LoadConfig implements the reading of the authentication configuration.
+func LoadConfig(cfg *Config, filePath string) error {
+	cfg.Enabled = true
+
+	info, err := os.Stat(filePath)
+	if err != nil {
+		return err
+	}
+	perm := info.Mode().Perm()
+	if perm != 0o600 {
+		return fmt.Errorf("config file %s has unsafe permissions: %o (expected 0600)", filePath, perm)
+	}
+
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		return err
+	}
+	err = yaml.Unmarshal(data, cfg)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// CheckUsernameAndPassword returns true if the provided username and password match any configured user.
+func CheckUsernameAndPassword(cfg *Config, username, password string) bool {
+	username = strings.TrimSpace(username)
+	password = strings.TrimSpace(password)
+
+	for _, user := range cfg.Users {
+		storedUsername := strings.TrimSpace(user.Username)
+		storedPassword := strings.TrimSpace(user.Password)
+
+		// Convert to []byte
+		usernameBytes := []byte(username)
+		storedUsernameBytes := []byte(storedUsername)
+		passwordBytes := []byte(password)
+		storedPasswordBytes := []byte(storedPassword)
+
+		// Length must match
+		if len(usernameBytes) != len(storedUsernameBytes) || len(passwordBytes) != len(storedPasswordBytes) {
+			continue
+		}
+
+		// Use constant-time comparison
+		usernameMatch := subtle.ConstantTimeCompare(usernameBytes, storedUsernameBytes) == 1
+		passwordMatch := subtle.ConstantTimeCompare(passwordBytes, storedPasswordBytes) == 1
+
+		if usernameMatch && passwordMatch {
+			return true
+		}
+	}
+	return false
+}
diff --git a/bydbctl/internal/cmd/auth_test.go b/bydbctl/internal/cmd/auth_test.go
new file mode 100644
index 00000000..351358a7
--- /dev/null
+++ b/bydbctl/internal/cmd/auth_test.go
@@ -0,0 +1,164 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package cmd_test
+
+import (
+	"embed"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/google/uuid"
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"github.com/spf13/cobra"
+	"github.com/zenizh/go-capturer"
+	"sigs.k8s.io/yaml"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	serverAuth "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+	"github.com/apache/skywalking-banyandb/bydbctl/internal/cmd"
+	"github.com/apache/skywalking-banyandb/pkg/test/helpers"
+	"github.com/apache/skywalking-banyandb/pkg/test/setup"
+)
+
+//go:embed testdata/*.yaml
+var configFS embed.FS
+
+var _ = g.Describe("bydbctl test with authentication", func() {
+	var deferFn func()
+	var httpAddr string
+	var rootCmd *cobra.Command
+	var testUser serverAuth.User
+
+	g.BeforeEach(func() {
+		// load server config.yaml
+		cfgBytes, err := configFS.ReadFile("testdata/config.yaml")
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		tempServerCfg := filepath.Join(os.TempDir(), fmt.Sprintf(".bydb-%s.yaml", uuid.New().String()))
+		err = os.WriteFile(tempServerCfg, cfgBytes, 0o600)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		authCfgFile := tempServerCfg
+		info, _ := os.Stat(authCfgFile)
+		gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+		var cfg serverAuth.Config
+		err = yaml.Unmarshal(cfgBytes, &cfg)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		// take the first user as the test user
+		gm.Expect(len(cfg.Users)).Should(gm.BeNumerically(">", 0))
+		testUser = cfg.Users[0]
+		// Username and password must be provided because health checks require authentication when --enable-health-auth=true is enabled.
+		_, httpAddr, deferFn = setup.EmptyStandaloneWithAuth(
+			testUser.Username, testUser.Password,
+			fmt.Sprintf("--auth-config-file=%s", authCfgFile),
+			"--enable-health-auth=true",
+		)
+		httpAddr = httpSchema + httpAddr
+		rootCmd = &cobra.Command{Use: "root"}
+		cmd.RootCmdFlags(rootCmd)
+	})
+
+	g.It("list groups and health check with correct username and password from command line", func() {
+		rootCmd.SetArgs([]string{"group", "list", "-a", httpAddr, "-u", testUser.Username, "-p", testUser.Password})
+		err := rootCmd.Execute()
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+
+		rootCmd.SetArgs([]string{"health", "-a", httpAddr, "-u", testUser.Username, "-p", testUser.Password})
+		err = rootCmd.Execute()
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+	})
+
+	g.It("list groups and health check with wrong username and password", func() {
+		// it will send http request with username = "admin" and password = ""
+		rootCmd.SetArgs([]string{"group", "list", "-a", httpAddr, "-u", "admin", "-p", testUser.Password + "wrong"})
+		err := rootCmd.Execute()
+		gm.Expect(err).To(gm.HaveOccurred())
+
+		rootCmd.SetArgs([]string{"health", "-a", httpAddr, "-u", "admin", "-p", testUser.Password + "wrong"})
+		err = rootCmd.Execute()
+		gm.Expect(err).To(gm.HaveOccurred())
+	})
+
+	g.It("create and get a group and health check with correct username and password in bydbctlCfgFile", func() {
+		clientCfgBytes, err := configFS.ReadFile("testdata/.bydbctl.yaml")
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		tempBydbctl := filepath.Join(os.TempDir(), fmt.Sprintf(".bydbctl-%s.yaml", uuid.New().String()))
+		err = os.WriteFile(tempBydbctl, clientCfgBytes, 0o600)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		bydbctlCfgFile := tempBydbctl
+		info, _ := os.Stat(bydbctlCfgFile)
+		gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+		rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "create", "-a", httpAddr, "-f", "-"})
+		rootCmd.SetIn(strings.NewReader(`
+metadata:
+  name: group1
+catalog: CATALOG_STREAM
+resource_opts:
+  shard_num: 2
+  segment_interval:
+    unit: UNIT_DAY
+    num: 1
+  ttl:
+    unit: UNIT_DAY
+    num: 7`))
+		out := capturer.CaptureStdout(func() {
+			err = rootCmd.Execute()
+			gm.Expect(err).NotTo(gm.HaveOccurred())
+		})
+		gm.Expect(out).To(gm.ContainSubstring("group group1 is created"))
+
+		rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "-a", httpAddr, "get", "-g", "group1"})
+		out = capturer.CaptureStdout(func() {
+			err = rootCmd.Execute()
+			gm.Expect(err).NotTo(gm.HaveOccurred())
+		})
+		resp := new(databasev1.GroupRegistryServiceGetResponse)
+		helpers.UnmarshalYAML([]byte(out), resp)
+		gm.Expect(resp.Group.Metadata.Name).To(gm.Equal("group1"))
+
+		rootCmd.SetArgs([]string{"health", "-a", httpAddr})
+		err = rootCmd.Execute()
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+	})
+
+	g.It("list groups and health check with wrong username and password in bydbctlCfgFile", func() {
+		clientCfgBytes, err := configFS.ReadFile("testdata/.bydbctl1.yaml")
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		tempBydbctl := filepath.Join(os.TempDir(), fmt.Sprintf(".bydbctl-%s.yaml", uuid.New().String()))
+		err = os.WriteFile(tempBydbctl, clientCfgBytes, 0o600)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		bydbctlCfgFile := tempBydbctl
+		info, _ := os.Stat(bydbctlCfgFile)
+		gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+		rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "list", "-a", httpAddr})
+		err = rootCmd.Execute()
+		gm.Expect(err).To(gm.HaveOccurred())
+
+		rootCmd.SetArgs([]string{"health", "-a", httpAddr})
+		err = rootCmd.Execute()
+		gm.Expect(err).To(gm.HaveOccurred())
+	})
+
+	g.AfterEach(func() {
+		deferFn()
+	})
+})
diff --git a/bydbctl/internal/cmd/health_check.go b/bydbctl/internal/cmd/health_check.go
index 2fe2cd0e..de63956f 100644
--- a/bydbctl/internal/cmd/health_check.go
+++ b/bydbctl/internal/cmd/health_check.go
@@ -23,6 +23,7 @@ import (
 
 	"github.com/go-resty/resty/v2"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
 	"google.golang.org/grpc"
 
 	"github.com/apache/skywalking-banyandb/pkg/grpchelper"
@@ -50,7 +51,7 @@ func newHealthCheckCmd() *cobra.Command {
 			if err != nil {
 				return err
 			}
-			err = helpers.HealthCheck(grpcAddr, 10*time.Second, 10*time.Second, opts...)()
+			err = healthCheck(grpcAddr, 10*time.Second, 10*time.Second, username, password, opts...)
 			if err == nil {
 				fmt.Println("connected")
 			}
@@ -61,3 +62,12 @@ func newHealthCheckCmd() *cobra.Command {
 	bindTLSRelatedFlag(healthCheckCmd)
 	return healthCheckCmd
 }
+
+func healthCheck(grpcAddr string, connTimeout, rpcTimeout time.Duration, username, password string, opts ...grpc.DialOption) error {
+	if username != "" {
+		return helpers.HealthCheckWithAuth(grpcAddr, connTimeout, rpcTimeout, username, password, opts...)()
+	}
+	username = viper.GetString("username")
+	password = viper.GetString("password")
+	return helpers.HealthCheckWithAuth(grpcAddr, connTimeout, rpcTimeout, username, password, opts...)()
+}
diff --git a/bydbctl/internal/cmd/rest.go b/bydbctl/internal/cmd/rest.go
index 88ed2dfe..3608128f 100644
--- a/bydbctl/internal/cmd/rest.go
+++ b/bydbctl/internal/cmd/rest.go
@@ -23,6 +23,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"net/http"
 	"os"
 	"time"
 
@@ -37,6 +38,7 @@ import (
 	"sigs.k8s.io/yaml"
 
 	"github.com/apache/skywalking-banyandb/bydbctl/pkg/file"
+	"github.com/apache/skywalking-banyandb/pkg/auth"
 )
 
 const (
@@ -337,6 +339,11 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
 			client.SetTLSClientConfig(&config)
 		}
 		req := client.R()
+		// Add req headers.
+		authHeader := getAuthHeader()
+		if authHeader != "" {
+			req.Header.Set("Authorization", authHeader)
+		}
 		resp, err := fn(request{
 			reqBody: r,
 			req:     req,
@@ -351,6 +358,9 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
 			s := status.FromProto(st)
 			return s.Err()
 		}
+		if resp.StatusCode() != http.StatusOK {
+			return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode())
+		}
 		err = printer(i, r, bd)
 		if err != nil {
 			return err
@@ -359,3 +369,16 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
 
 	return nil
 }
+
+func getAuthHeader() string {
+	if username != "" {
+		return auth.GenerateBasicAuthHeader(username, password)
+	}
+	// If the command line username is not available, then look for it in the configuration file.
+	user := viper.GetString("username")
+	pwd := viper.GetString("password")
+	if user == "" && pwd == "" {
+		return ""
+	}
+	return auth.GenerateBasicAuthHeader(user, pwd)
+}
diff --git a/bydbctl/internal/cmd/root.go b/bydbctl/internal/cmd/root.go
index bb9cde00..c83f2752 100644
--- a/bydbctl/internal/cmd/root.go
+++ b/bydbctl/internal/cmd/root.go
@@ -26,10 +26,14 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
+	"github.com/apache/skywalking-banyandb/pkg/config"
 	"github.com/apache/skywalking-banyandb/pkg/version"
 )
 
-const pathTemp = "/{group}/{name}"
+const (
+	pathTemp  = "/{group}/{name}"
+	envPrefix = "BYDBCTL"
+)
 
 var (
 	filePath  string
@@ -40,6 +44,8 @@ var (
 	enableTLS bool
 	insecure  bool
 	cert      string
+	username  string
+	password  string
 	rootCmd   = &cobra.Command{
 		DisableAutoGenTag: true,
 		Version:           version.Build(),
@@ -65,9 +71,13 @@ func RootCmdFlags(command *cobra.Command) {
 	command.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bydbctl.yaml)")
 	command.PersistentFlags().StringP("group", "g", "", "If present, list objects in this group.")
 	command.PersistentFlags().StringP("addr", "a", "", "Server's address, the format is Schema://Domain:Port")
+	command.PersistentFlags().StringVarP(&username, "username", "u", "", "Username for authentication")
+	command.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for authentication")
 	_ = viper.BindPFlag("group", command.PersistentFlags().Lookup("group"))
 	_ = viper.BindPFlag("addr", command.PersistentFlags().Lookup("addr"))
 	viper.SetDefault("addr", "http://localhost:17913")
+	_ = viper.BindPFlag("username", command.PersistentFlags().Lookup("username"))
+	_ = viper.BindPFlag("password", command.PersistentFlags().Lookup("password"))
 
 	command.AddCommand(newGroupCmd(), newUseCmd(), newStreamCmd(), newMeasureCmd(), newTopnCmd(),
 		newIndexRuleCmd(), newIndexRuleBindingCmd(), newPropertyCmd(), newTraceCmd(), newHealthCheckCmd(), newAnalyzeCmd())
@@ -96,14 +106,27 @@ func initConfig() {
 		viper.SetConfigName(".bydbctl")
 	}
 
+	viper.SetEnvPrefix(envPrefix)
 	viper.AutomaticEnv()
+	err := config.BindFlags(rootCmd.PersistentFlags(), viper.GetViper(), envPrefix)
+	if err != nil {
+		cobra.CheckErr(err)
+	}
 
 	readCfg := func() error {
 		if err := viper.ReadInConfig(); err != nil {
 			return err
 		}
+		configFile := viper.ConfigFileUsed()
+		info, err := os.Stat(configFile)
+		if err != nil {
+			return fmt.Errorf("unable to stat config file: %w", err)
+		}
+		if info.Mode().Perm() != 0o600 {
+			fmt.Printf("config file %s has unsafe permissions: %o (expected 0600)", configFile, info.Mode().Perm())
+		}
 		// Dump this to stderr in case of mixing up response yaml
-		fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
+		fmt.Fprintln(os.Stderr, "Using config file:", configFile)
 		return nil
 	}
 
@@ -112,6 +135,12 @@ func initConfig() {
 			cobra.CheckErr(err)
 		}
 		cobra.CheckErr(viper.SafeWriteConfig())
+		// Reload config to ensure Viper updates ConfigFileUsed(), avoiding empty path for chmod
+		cobra.CheckErr(viper.ReadInConfig())
+		configFile := viper.ConfigFileUsed()
+		if err := os.Chmod(configFile, 0o600); err != nil {
+			cobra.CheckErr(fmt.Errorf("failed to set permissions on config file %s: %w", configFile, err))
+		}
 		cobra.CheckErr(readCfg())
 	}
 }
diff --git a/bydbctl/internal/cmd/testdata/.bydbctl.yaml b/bydbctl/internal/cmd/testdata/.bydbctl.yaml
new file mode 100644
index 00000000..31a538cb
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/.bydbctl.yaml
@@ -0,0 +1,3 @@
+group: ""
+username: "test"
+password: "123456"
\ No newline at end of file
diff --git a/bydbctl/internal/cmd/testdata/.bydbctl1.yaml b/bydbctl/internal/cmd/testdata/.bydbctl1.yaml
new file mode 100644
index 00000000..f2845ce3
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/.bydbctl1.yaml
@@ -0,0 +1,3 @@
+group: ""
+username: "test"
+password: "wrong-password"
\ No newline at end of file
diff --git a/bydbctl/internal/cmd/testdata/config.yaml b/bydbctl/internal/cmd/testdata/config.yaml
new file mode 100644
index 00000000..6c6d24dd
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/config.yaml
@@ -0,0 +1,5 @@
+users:
+  - username: admin
+    password: 123456
+  - username: test
+    password: 123456
\ No newline at end of file
diff --git a/docs/operation/security.md b/docs/operation/security.md
index fbddd5ea..748e8dd4 100644
--- a/docs/operation/security.md
+++ b/docs/operation/security.md
@@ -4,6 +4,89 @@ Security is a critical aspect of any application. In this section, we will discu
 
 ## Authentication
 
+BanyanDB supports username and password-based authentication for both gRPC and HTTP endpoints. This guide explains how to configure and use this feature for both the BanyanDB server and the `bydbctl` command-line tool.
+
+### Basic Authentication
+
+To enable authentication on the BanyanDB server, you need to create a **YAML configuration file** that defines your users and their passwords.
+
+#### Create the Configuration File
+
+The configuration file should contain a list of users. For security, it's highly recommended to use strong, unique passwords instead of the examples provided below.
+
+```yaml
+users:
+  - username: admin
+    password: StrongPassword123
+  - username: dev_user
+    password: AnotherStrongPassword456
+```
+
+#### Set File Permissions
+
+To protect your credentials, the configuration file **must** have read/write permissions only for the owner. Set the correct permissions using the following command:
+
+```shell
+chmod 600 /path/to/auth_config.yaml
+```
+
+This command ensures that only the file's owner can read or modify the contents.
+
+#### Start the BanyanDB Server
+
+Finally, start the BanyanDB server with the `--auth-config-file` flag, pointing to the file you just created:
+
+```shell
+banyand liaison --auth-config-file=/path/to/auth_config.yaml
+```
+
+### Authenticating with `bydbctl`
+
+When the BanyanDB server has authentication enabled, you must provide a username and password with your `bydbctl` commands. There are two ways to do this.
+
+#### Option 1: Use Command-Line Flags
+
+You can provide the authentication details directly in your `bydbctl` command using the `-u` and `-p` flags.
+
+```shell
+bydbctl group get -g group1 -a http://localhost:17913 -u admin -p StrongPassword123
+```
+
+> **Note:** The `addr`, `group`, `username`, and `password` parameters will be automatically saved to a file named `.bydbctl.yaml` in your home directory if it doesn't already exist. This file will also have the permissions `0600` for security.
+
+#### Option 2: Use a `bydbctl` Configuration File
+
+You can create a separate configuration file for `bydbctl` to store your connection and authentication details.
+
+#### Create the Configuration File
+
+Create a YAML file containing your connection details. For example:
+
+```yaml
+addr: http://localhost:17913
+group: group1
+username: admin
+password: StrongPassword123
+```
+
+#### Set File Permissions
+
+Just like with the server's authentication file, the `bydbctl` configuration file should have secure permissions:
+
+```shell
+chmod 600 /path/to/bydbctl_config.yaml
+```
+
+#### Run `bydbctl` with the Configuration File
+
+You can then run `bydbctl` by specifying the path to your configuration file with the `--config` flag.
+
+```shell
+bydbctl --config /path/to/bydbctl_config.yaml group list
+```
+
+### External TLS (Client ↔ Server)
+
 BanyanDB supports TLS for secure communication between servers. The following flags are used to configure TLS:
 
 - `--tls`: gRPC Connection uses TLS if true, else plain TCP.
diff --git a/go.mod b/go.mod
index 95c21237..a20c0fc3 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
 	github.com/go-chi/chi/v5 v5.2.1
 	github.com/go-resty/resty/v2 v2.16.5
 	github.com/google/go-cmp v0.7.0
+	github.com/google/uuid v1.6.0
 	github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
 	github.com/hashicorp/golang-lru v1.0.2
@@ -86,7 +87,6 @@ require (
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
-	github.com/google/uuid v1.6.0 // indirect
 	github.com/kamstrup/intmap v0.5.1 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 	github.com/machinebox/graphql v0.2.2 // indirect
diff --git a/pkg/auth/util.go b/pkg/auth/util.go
new file mode 100644
index 00000000..52ca20ae
--- /dev/null
+++ b/pkg/auth/util.go
@@ -0,0 +1,31 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package auth contains authentication helper functions.
+package auth
+
+import (
+	"encoding/base64"
+	"fmt"
+)
+
+// GenerateBasicAuthHeader creates a Basic Auth header from username and password.
+func GenerateBasicAuthHeader(username, password string) string {
+	credentials := fmt.Sprintf("%s:%s", username, password)
+	encoded := base64.StdEncoding.EncodeToString([]byte(credentials))
+	return "Basic " + encoded
+}
diff --git a/pkg/cmdsetup/liaison.go b/pkg/cmdsetup/liaison.go
index 9ad92ab4..97f3f091 100644
--- a/pkg/cmdsetup/liaison.go
+++ b/pkg/cmdsetup/liaison.go
@@ -81,7 +81,7 @@ func newLiaisonCmd(runners ...run.Unit) *cobra.Command {
 		PropertyNodeRegistry:       grpc.NewClusterNodeRegistry(data.TopicPropertyUpdate, tire2Client, propertyNodeSel),
 	}, metricSvc)
 	profSvc := observability.NewProfService()
-	httpServer := http.NewServer()
+	httpServer := http.NewServer(grpcServer.GetAuthCfg())
 	var units []run.Unit
 	units = append(units, runners...)
 	units = append(units,
diff --git a/pkg/cmdsetup/standalone.go b/pkg/cmdsetup/standalone.go
index 0105011d..70abc4d3 100644
--- a/pkg/cmdsetup/standalone.go
+++ b/pkg/cmdsetup/standalone.go
@@ -76,7 +76,7 @@ func newStandaloneCmd(runners ...run.Unit) *cobra.Command {
 		PropertyNodeRegistry:       nr,
 	}, metricSvc)
 	profSvc := observability.NewProfService()
-	httpServer := http.NewServer()
+	httpServer := http.NewServer(grpcServer.GetAuthCfg())
 
 	var units []run.Unit
 	units = append(units, runners...)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d16134a2..7e96558f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -79,11 +79,11 @@ func (c *config) initializeConfig(fs *pflag.FlagSet) error {
 	v.AutomaticEnv()
 
 	// Bind the current command's flags to viper
-	return bindFlags(fs, v)
+	return BindFlags(fs, v, envPrefix)
 }
 
-// Bind each cobra flag to its associated viper configuration (config file and environment variable).
-func bindFlags(fs *pflag.FlagSet, v *viper.Viper) error {
+// BindFlags bind each cobra flag to its associated viper configuration (config file and environment variable).
+func BindFlags(fs *pflag.FlagSet, v *viper.Viper, envPrefix string) error {
 	var err error
 	fs.VisitAll(func(f *pflag.Flag) {
 		// Environment variables can't have dashes in them, so bind them to their equivalent
diff --git a/pkg/grpchelper/client.go b/pkg/grpchelper/client.go
index db583cfe..085f675a 100644
--- a/pkg/grpchelper/client.go
+++ b/pkg/grpchelper/client.go
@@ -65,6 +65,11 @@ func SecureOptions(dest []grpc.DialOption, enabled, insecure bool, cert string)
 
 // Conn returns a gRPC client connection once connecting the server.
 func Conn(addr string, healthCheckTimeout time.Duration, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
+	return ConnWithAuth(addr, healthCheckTimeout, "", "", opts...)
+}
+
+// ConnWithAuth returns a gRPC client connection once connecting the server with Auth.
+func ConnWithAuth(addr string, healthCheckTimeout time.Duration, username, password string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
 	l := logger.GetLogger("grpc-helper")
 
 	conn, err := grpc.NewClient(addr, opts...)
@@ -82,6 +87,11 @@ func Conn(addr string, healthCheckTimeout time.Duration, opts ...grpc.DialOption
 		}
 		ctx := context.Background()
 		ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
+		md := metadata.Pairs(
+			"username", username,
+			"password", password,
+		)
+		ctx = metadata.NewOutgoingContext(ctx, md)
 		_, err = healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{Service: ""})
 		cancel()
 		if err == nil {
@@ -98,7 +108,8 @@ func Request(ctx context.Context, rpcTimeout time.Duration, fn func(rpcCtx conte
 	rpcStart := time.Now()
 	rpcCtx, rpcCancel := context.WithTimeout(ctx, rpcTimeout)
 	defer rpcCancel()
-	rpcCtx = metadata.NewOutgoingContext(rpcCtx, make(metadata.MD))
+	md, _ := metadata.FromOutgoingContext(ctx)
+	rpcCtx = metadata.NewOutgoingContext(rpcCtx, md)
 	l := logger.GetLogger("grpc-helper")
 
 	err := fn(rpcCtx)
diff --git a/pkg/test/helpers/grpc_health.go b/pkg/test/helpers/grpc_health.go
index 4b9274cc..fd049d8e 100644
--- a/pkg/test/helpers/grpc_health.go
+++ b/pkg/test/helpers/grpc_health.go
@@ -24,6 +24,7 @@ import (
 
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/health/grpc_health_v1"
+	"google.golang.org/grpc/metadata"
 
 	"github.com/apache/skywalking-banyandb/pkg/grpchelper"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
@@ -33,14 +34,24 @@ var errServiceUnhealthy = errors.New("service is unhealthy")
 
 // HealthCheck returns a function for ginkgo "Eventually" poll it repeatedly to check whether a gRPC server is ready.
 func HealthCheck(addr string, connTimeout time.Duration, rpcTimeout time.Duration, opts ...grpc.DialOption) func() error {
+	return HealthCheckWithAuth(addr, connTimeout, rpcTimeout, "", "", opts...)
+}
+
+// HealthCheckWithAuth returns a function for ginkgo "Eventually" poll it repeatedly to check whether a gRPC server is ready with Auth.
+func HealthCheckWithAuth(addr string, connTimeout time.Duration, rpcTimeout time.Duration, username, password string, opts ...grpc.DialOption) func() error {
 	return func() error {
-		conn, err := grpchelper.Conn(addr, connTimeout, opts...)
+		conn, err := grpchelper.ConnWithAuth(addr, connTimeout, username, password, opts...)
 		if err != nil {
 			return err
 		}
 		defer conn.Close()
 		var resp *grpc_health_v1.HealthCheckResponse
 		if err := grpchelper.Request(context.Background(), rpcTimeout, func(rpcCtx context.Context) (err error) {
+			md := metadata.Pairs(
+				"username", username,
+				"password", password,
+			)
+			rpcCtx = metadata.NewOutgoingContext(rpcCtx, md)
 			resp, err = grpc_health_v1.NewHealthClient(conn).Check(rpcCtx,
 				&grpc_health_v1.HealthCheckRequest{
 					Service: "",
diff --git a/pkg/test/helpers/http_health.go b/pkg/test/helpers/http_health.go
index 80d7d621..78147c7c 100644
--- a/pkg/test/helpers/http_health.go
+++ b/pkg/test/helpers/http_health.go
@@ -27,11 +27,17 @@ import (
 
 	"github.com/go-resty/resty/v2"
 
+	"github.com/apache/skywalking-banyandb/pkg/auth"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 )
 
 // HTTPHealthCheck returns a function for ginkgo "Eventually" poll it repeatedly to check whether a HTTP server is ready.
 func HTTPHealthCheck(addr, cert string) func() error {
+	return HTTPHealthCheckWithAuth(addr, cert, "", "")
+}
+
+// HTTPHealthCheckWithAuth returns a function for ginkgo "Eventually" poll it repeatedly to check whether a HTTP server is ready with Auth.
+func HTTPHealthCheckWithAuth(addr, cert, username, password string) func() error {
 	return func() error {
 		client := resty.New()
 		schema := "http"
@@ -50,9 +56,11 @@ func HTTPHealthCheck(addr, cert string) func() error {
 			client.SetTLSClientConfig(&config)
 			schema = "https"
 		}
-		resp, err := client.R().
-			SetHeader("Accept", "application/json").
-			Get(fmt.Sprintf("%s://%s/api/healthz", schema, addr))
+		req := client.R().SetHeader("Accept", "application/json")
+		if username != "" {
+			req.SetHeader("Authorization", auth.GenerateBasicAuthHeader(username, password))
+		}
+		resp, err := req.Get(fmt.Sprintf("%s://%s/api/healthz", schema, addr))
 		if err != nil {
 			time.Sleep(1 * time.Second)
 			return err
diff --git a/pkg/test/setup/setup.go b/pkg/test/setup/setup.go
index dfb91aa0..cb8a6ac0 100644
--- a/pkg/test/setup/setup.go
+++ b/pkg/test/setup/setup.go
@@ -50,7 +50,15 @@ func Standalone(flags ...string) (string, string, func()) {
 	return StandaloneWithSchemaLoaders([]SchemaLoader{
 		&preloadService{name: "stream"},
 		&preloadService{name: "measure"},
-	}, "", "", flags...)
+	}, "", "", "", "", flags...)
+}
+
+// StandaloneWithAuth wires standalone modules to build a testing ready runtime with Auth.
+func StandaloneWithAuth(username, password string, flags ...string) (string, string, func()) {
+	return StandaloneWithSchemaLoaders([]SchemaLoader{
+		&preloadService{name: "stream"},
+		&preloadService{name: "measure"},
+	}, "", "", username, password, flags...)
 }
 
 // StandaloneWithTLS wires standalone modules to build a testing ready runtime with TLS enabled.
@@ -58,22 +66,27 @@ func StandaloneWithTLS(certFile, keyFile string, flags ...string) (string, strin
 	return StandaloneWithSchemaLoaders([]SchemaLoader{
 		&preloadService{name: "stream"},
 		&preloadService{name: "measure"},
-	}, certFile, keyFile, flags...)
+	}, certFile, keyFile, "", "", flags...)
 }
 
 // EmptyStandalone wires standalone modules to build a testing ready runtime.
 func EmptyStandalone(flags ...string) (string, string, func()) {
-	return StandaloneWithSchemaLoaders(nil, "", "", flags...)
+	return StandaloneWithSchemaLoaders(nil, "", "", "", "", flags...)
+}
+
+// EmptyStandaloneWithAuth wires standalone modules to build a testing ready runtime with Auth.
+func EmptyStandaloneWithAuth(username, password string, flags ...string) (string, string, func()) {
+	return StandaloneWithSchemaLoaders(nil, "", "", username, password, flags...)
 }
 
 // StandaloneWithSchemaLoaders wires standalone modules to build a testing ready runtime. It also allows to preload schema.
-func StandaloneWithSchemaLoaders(schemaLoaders []SchemaLoader, certFile, keyFile string, flags ...string) (string, string, func()) {
+func StandaloneWithSchemaLoaders(schemaLoaders []SchemaLoader, certFile, keyFile string, username, password string, flags ...string) (string, string, func()) {
 	path, deferFn, err := test.NewSpace()
 	gomega.Expect(err).NotTo(gomega.HaveOccurred())
 	var ports []int
 	ports, err = test.AllocateFreePorts(4)
 	gomega.Expect(err).NotTo(gomega.HaveOccurred())
-	addr, httpAddr, closeFn := standaloneServer(path, ports, schemaLoaders, certFile, keyFile, flags...)
+	addr, httpAddr, closeFn := standaloneServerWithAuth(path, ports, schemaLoaders, certFile, keyFile, username, password, flags...)
 	return addr, httpAddr, func() {
 		closeFn()
 		deferFn()
@@ -99,6 +112,12 @@ func EmptyClosableStandalone(path string, ports []int, flags ...string) (string,
 }
 
 func standaloneServer(path string, ports []int, schemaLoaders []SchemaLoader, certFile, keyFile string, flags ...string) (string, string, func()) {
+	return standaloneServerWithAuth(path, ports, schemaLoaders, certFile, keyFile, "", "", flags...)
+}
+
+func standaloneServerWithAuth(path string, ports []int, schemaLoaders []SchemaLoader, certFile, keyFile string,
+	username, password string, flags ...string,
+) (string, string, func()) {
 	addr := fmt.Sprintf("%s:%d", host, ports[0])
 	httpAddr := fmt.Sprintf("%s:%d", host, ports[1])
 	endpoint := fmt.Sprintf("http://%s:%d", host, ports[2])
@@ -131,14 +150,16 @@ func standaloneServer(path string, ports []int, schemaLoaders []SchemaLoader, ce
 	if tlsEnabled {
 		creds, err := credentials.NewClientTLSFromFile(certFile, "localhost")
 		gomega.Expect(err).NotTo(gomega.HaveOccurred())
-		gomega.Eventually(helpers.HealthCheck(addr, 10*time.Second, 10*time.Second, grpclib.WithTransportCredentials(creds)), testflags.EventuallyTimeout).
+		gomega.Eventually(helpers.HealthCheckWithAuth(addr, 10*time.Second, 10*time.Second,
+			username, password, grpclib.WithTransportCredentials(creds)), testflags.EventuallyTimeout).
 			Should(gomega.Succeed())
-		gomega.Eventually(helpers.HTTPHealthCheck(httpAddr, certFile), testflags.EventuallyTimeout).Should(gomega.Succeed())
+		gomega.Eventually(helpers.HTTPHealthCheckWithAuth(httpAddr, certFile, username, password), testflags.EventuallyTimeout).Should(gomega.Succeed())
 	} else {
 		gomega.Eventually(
-			helpers.HealthCheck(addr, 10*time.Second, 10*time.Second, grpclib.WithTransportCredentials(insecure.NewCredentials())),
+			helpers.HealthCheckWithAuth(addr, 10*time.Second, 10*time.Second,
+				username, password, grpclib.WithTransportCredentials(insecure.NewCredentials())),
 			testflags.EventuallyTimeout).Should(gomega.Succeed())
-		gomega.Eventually(helpers.HTTPHealthCheck(httpAddr, ""), testflags.EventuallyTimeout).Should(gomega.Succeed())
+		gomega.Eventually(helpers.HTTPHealthCheckWithAuth(httpAddr, "", username, password), testflags.EventuallyTimeout).Should(gomega.Succeed())
 	}
 
 	if schemaLoaders != nil {
diff --git a/test/cases/measure/data/data.go b/test/cases/measure/data/data.go
index f4c78699..77274d08 100644
--- a/test/cases/measure/data/data.go
+++ b/test/cases/measure/data/data.go
@@ -29,6 +29,7 @@ import (
 	g "github.com/onsi/ginkgo/v2"
 	gm "github.com/onsi/gomega"
 	grpclib "google.golang.org/grpc"
+	"google.golang.org/grpc/metadata"
 	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/testing/protocmp"
 	"google.golang.org/protobuf/types/known/timestamppb"
@@ -47,8 +48,7 @@ var inputFS embed.FS
 //go:embed want/*.yaml
 var wantFS embed.FS
 
-// VerifyFn verify whether the query response matches the wanted result.
-var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
+func verifyWithContext(ctx context.Context, innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
 	i, err := inputFS.ReadFile("input/" + args.Input + ".yaml")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 	query := &measurev1.QueryRequest{}
@@ -56,7 +56,6 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
 	query.TimeRange = helpers.TimeRange(args, sharedContext)
 	query.Stages = args.Stages
 	c := measurev1.NewMeasureServiceClient(sharedContext.Connection)
-	ctx := context.Background()
 	resp, err := c.Query(ctx, query)
 	if args.WantErr {
 		if err == nil {
@@ -111,6 +110,21 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
 	innerGm.Expect(resp.Trace.GetSpans()).NotTo(gm.BeEmpty())
 }
 
+// VerifyFn verify whether the query response matches the wanted result.
+var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
+	ctx := context.Background()
+	verifyWithContext(ctx, innerGm, sharedContext, args)
+}
+
+// VerifyFnWithAuth verify whether the query response matches the wanted result with Auth.
+var VerifyFnWithAuth = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args, username, password string) {
+	ctx := metadata.AppendToOutgoingContext(context.Background(),
+		"username", username,
+		"password", password,
+	)
+	verifyWithContext(ctx, innerGm, sharedContext, args)
+}
+
 //go:embed testdata/*.json
 var dataFS embed.FS
 
@@ -134,18 +148,27 @@ func loadData(md *commonv1.Metadata, measure measurev1.MeasureService_WriteClien
 func Write(conn *grpclib.ClientConn, name, group, dataFile string,
 	baseTime time.Time, interval time.Duration,
 ) {
+	WriteWithAuth(conn, name, group, dataFile, baseTime, interval, "", "")
+}
+
+// WriteWithAuth data into the server with Auth.
+func WriteWithAuth(conn *grpclib.ClientConn, name, group, dataFile string,
+	baseTime time.Time, interval time.Duration, username, password string,
+) {
+	ctx := context.Background()
+	md := metadata.Pairs("username", username, "password", password)
+	ctx = metadata.NewOutgoingContext(ctx, md)
 	metadata := &commonv1.Metadata{
 		Name:  name,
 		Group: group,
 	}
 
 	schema := databasev1.NewMeasureRegistryServiceClient(conn)
-	resp, err := schema.Get(context.Background(), &databasev1.MeasureRegistryServiceGetRequest{Metadata: metadata})
+	resp, err := schema.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{Metadata: metadata})
 	gm.Expect(err).NotTo(gm.HaveOccurred())
 	metadata = resp.GetMeasure().GetMetadata()
 
 	c := measurev1.NewMeasureServiceClient(conn)
-	ctx := context.Background()
 	writeClient, err := c.Write(ctx)
 	gm.Expect(err).NotTo(gm.HaveOccurred())
 	loadData(metadata, writeClient, dataFile, baseTime, interval)
diff --git a/test/integration/standalone/other/auth_test.go b/test/integration/standalone/other/auth_test.go
new file mode 100644
index 00000000..20cc06bc
--- /dev/null
+++ b/test/integration/standalone/other/auth_test.go
@@ -0,0 +1,210 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package integration_other_test
+
+import (
+	"embed"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/google/uuid"
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"github.com/onsi/gomega/gleak"
+	grpclib "google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+	"sigs.k8s.io/yaml"
+
+	serverAuth "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+	"github.com/apache/skywalking-banyandb/pkg/auth"
+	"github.com/apache/skywalking-banyandb/pkg/grpchelper"
+	"github.com/apache/skywalking-banyandb/pkg/test/flags"
+	"github.com/apache/skywalking-banyandb/pkg/test/helpers"
+	"github.com/apache/skywalking-banyandb/pkg/test/setup"
+	"github.com/apache/skywalking-banyandb/pkg/timestamp"
+	casesMeasureData "github.com/apache/skywalking-banyandb/test/cases/measure/data"
+)
+
+//go:embed testdata/config.yaml
+var serverConfigFS embed.FS
+
+var _ = g.Describe("Query service_cpm_minute with authentication", func() {
+	var deferFn func()
+	var baseTime time.Time
+	var interval time.Duration
+	var conn *grpclib.ClientConn
+	var goods []gleak.Goroutine
+	var grpcAddr, httpAddr string
+	var testUser serverAuth.User
+
+	g.BeforeEach(func() {
+		// load server config.yaml
+		cfgBytes, err := serverConfigFS.ReadFile("testdata/config.yaml")
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		tempServerCfg := filepath.Join(os.TempDir(), fmt.Sprintf(".bydb-%s.yaml", uuid.New().String()))
+		err = os.WriteFile(tempServerCfg, cfgBytes, 0o600)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		authCfgFile := tempServerCfg
+		info, _ := os.Stat(authCfgFile)
+		gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+		var cfg serverAuth.Config
+		err = yaml.Unmarshal(cfgBytes, &cfg)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		// take the first user as the test user
+		gm.Expect(len(cfg.Users)).Should(gm.BeNumerically(">", 0))
+		testUser = cfg.Users[0]
+		// Username and password must be provided because health checks require authentication when --enable-health-auth=true is enabled.
+		grpcAddr, httpAddr, deferFn = setup.StandaloneWithAuth(
+			testUser.Username, testUser.Password,
+			fmt.Sprintf("--auth-config-file=%s", authCfgFile),
+			"--enable-health-auth=true",
+		)
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		conn, err = grpchelper.ConnWithAuth(grpcAddr, 10*time.Second, testUser.Username, testUser.Password, grpclib.WithTransportCredentials(insecure.NewCredentials()))
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		ns := timestamp.NowMilli().UnixNano()
+		baseTime = time.Unix(0, ns-ns%int64(time.Minute))
+		interval = 500 * time.Millisecond
+		casesMeasureData.WriteWithAuth(conn, "service_cpm_minute", "sw_metric", "service_cpm_minute_data.json", baseTime, interval, testUser.Username, testUser.Password)
+		goods = gleak.Goroutines()
+	})
+
+	g.AfterEach(func() {
+		gm.Expect(conn.Close()).To(gm.Succeed())
+		deferFn()
+		gm.Eventually(gleak.Goroutines, flags.EventuallyTimeout).ShouldNot(gleak.HaveLeaked(goods))
+	})
+
+	g.It("grpc query and healthcheck with correct username and password", func() {
+		gm.Eventually(func(innerGm gm.Gomega) {
+			casesMeasureData.VerifyFnWithAuth(innerGm, helpers.SharedContext{
+				Connection: conn,
+				BaseTime:   baseTime,
+			},
+				helpers.Args{Input: "all", Duration: 25 * time.Minute, Offset: -20 * time.Minute},
+				testUser.Username, testUser.Password)
+		}, flags.EventuallyTimeout).Should(gm.Succeed())
+
+		opts := make([]grpclib.DialOption, 0, 1)
+		opts, err := grpchelper.SecureOptions(opts, false, true, "")
+		gm.Expect(err).ToNot(gm.HaveOccurred())
+		gm.Eventually(func() error {
+			return helpers.HealthCheckWithAuth(grpcAddr, 10*time.Second, 10*time.Second, testUser.Username, testUser.Password, opts...)()
+		}, flags.EventuallyTimeout).Should(gm.Succeed())
+	})
+
+	g.It("grpc query and healthcheck with wrong username and password", func() {
+		gm.Eventually(func(innerGm gm.Gomega) {
+			casesMeasureData.VerifyFnWithAuth(innerGm, helpers.SharedContext{
+				Connection: conn,
+				BaseTime:   baseTime,
+			},
+				helpers.Args{Input: "all", Duration: 25 * time.Minute, Offset: -20 * time.Minute},
+				testUser.Username, testUser.Password+"wrong")
+		}, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+
+		opts := make([]grpclib.DialOption, 0, 1)
+		opts, err := grpchelper.SecureOptions(opts, false, true, "")
+		gm.Expect(err).ToNot(gm.HaveOccurred())
+		gm.Eventually(func() error {
+			return helpers.HealthCheckWithAuth(grpcAddr, 10*time.Second, 10*time.Second, testUser.Username, testUser.Password+"wrong", opts...)()
+		}, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+	})
+
+	g.It("http query and healthcheck with correct username and password", func() {
+		httpClient := &http.Client{}
+		groupListURL := fmt.Sprintf("http://%s/api/v1/group/schema/lists", httpAddr)
+		gm.Eventually(func() error {
+			req, err := http.NewRequest(http.MethodGet, groupListURL, nil)
+			if err != nil {
+				return err
+			}
+			req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password))
+			resp, respErr := httpClient.Do(req)
+			if respErr != nil {
+				return respErr
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+			}
+			return nil
+		}, flags.EventuallyTimeout).Should(gm.Succeed())
+
+		healthCheckURL := fmt.Sprintf("http://%s/api/healthz", httpAddr)
+		gm.Eventually(func() error {
+			req, err := http.NewRequest(http.MethodGet, healthCheckURL, nil)
+			if err != nil {
+				return err
+			}
+			req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password))
+			resp, respErr := httpClient.Do(req)
+			if respErr != nil {
+				return respErr
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+			}
+			return nil
+		}, flags.EventuallyTimeout).Should(gm.Succeed())
+	})
+
+	g.It("http query and healthcheck with wrong username and password", func() {
+		httpClient := &http.Client{}
+		groupListURL := fmt.Sprintf("http://%s/api/v1/group/schema/lists", httpAddr)
+		gm.Eventually(func() error {
+			req, err := http.NewRequest(http.MethodGet, groupListURL, nil)
+			if err != nil {
+				return err
+			}
+			req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password+"wrong"))
+			resp, respErr := httpClient.Do(req)
+			if respErr != nil {
+				return respErr
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+			}
+			return nil
+		}, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+
+		healthCheckURL := fmt.Sprintf("http://%s/api/healthz", httpAddr)
+		gm.Eventually(func() error {
+			req, err := http.NewRequest(http.MethodGet, healthCheckURL, nil)
+			if err != nil {
+				return err
+			}
+			req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password+"wrong"))
+			resp, respErr := httpClient.Do(req)
+			if respErr != nil {
+				return respErr
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+			}
+			return nil
+		}, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+	})
+})
diff --git a/test/integration/standalone/other/testdata/config.yaml b/test/integration/standalone/other/testdata/config.yaml
new file mode 100644
index 00000000..6c6d24dd
--- /dev/null
+++ b/test/integration/standalone/other/testdata/config.yaml
@@ -0,0 +1,5 @@
+users:
+  - username: admin
+    password: 123456
+  - username: test
+    password: 123456
\ No newline at end of file