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

FreeOnePlus pushed a commit to branch agent/sec-014-token-digest-storage
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git

commit 7821d5b1916adf31e0ec208e444e418f49e86c34
Author: FreeOnePlus <[email protected]>
AuthorDate: Wed Jul 29 23:06:06 2026 +0800

    fix: persist static tokens as digests
---
 .env.example                               |   3 +
 CHANGELOG.md                               |   3 +
 README.md                                  | 113 +++---
 doris_mcp_server/auth/token_manager.py     | 531 +++++++++++++++++++----------
 doris_mcp_server/utils/config.py           |  44 ++-
 doris_mcp_server/utils/secret_policy.py    |  51 +++
 test/security/test_token_digest_storage.py | 340 ++++++++++++++++++
 tokens.json                                |   9 +-
 8 files changed, 862 insertions(+), 232 deletions(-)

diff --git a/.env.example b/.env.example
index d511b84..900155f 100644
--- a/.env.example
+++ b/.env.example
@@ -84,6 +84,9 @@ TOKEN_HASH_ALGORITHM=sha256
 # No static bearer token is shipped. Generate one outside source control:
 # python -c 'import secrets; print(secrets.token_urlsafe(32))'
 # TOKEN_ADMIN=<paste-generated-value-here>
+# Managed tokens.json writes persist only a self-describing token digest and
+# return plaintext only at creation time. Legacy plaintext files are migrated
+# to digest-only version 2.0 on first successful load.
 
 # ===================================================================
 # Token Management Security Configuration (NEW in v0.6.0) - CRITICAL SECURITY 
SETTINGS
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81a80a7..b1185f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,9 @@ under **Unreleased** until a new version is selected and 
published.
   constraints.
 - Positioned Dynamic Client Registration as a compatibility fallback behind
   preconfigured clients and Client ID Metadata Documents.
+- Persisted static bearer tokens as self-describing SHA-256/SHA-512 digests,
+  with atomic owner-only writes and one-way migration from legacy plaintext
+  token files.
 
 ### Fixed
 
diff --git a/README.md b/README.md
index e02275a..003ec03 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,8 @@ field and do not persist or propagate the token in URLs.
   - All operations require admin authentication
   - Real-time IP validation
   - Complete audit logging
-  - **Automatic persistence** to `tokens.json`
+  - **Digest-only persistence** to `tokens.json`; plaintext is returned once
+    when a token is created
 
 > **🔐 Security Note**: The interface is designed for localhost administration 
 > only. It cannot be accessed remotely, ensuring maximum security for token 
 > management operations.
 
@@ -283,6 +284,7 @@ cp .env.example .env
     *   `DORIS_OAUTH_CIMD_MAX_CLIENTS`: Maximum discovered Client ID Metadata 
clients held in memory (default: 1000)
     *   `TOKEN_FILE_PATH`: Path to tokens.json file for token management 
(default: tokens.json)
     *   `TOKEN_HOT_RELOAD`: Enable hot reloading of token configuration 
(default: true)
+    *   `TOKEN_HASH_ALGORITHM`: Digest algorithm for newly created static 
tokens (`sha256` or `sha512`; default: `sha256`)
     *   `TOKEN_<ID>`: Explicit static bearer token; each active token must be a
         securely generated value of at least 32 characters
 *   **Legacy Security Configuration**:
@@ -815,17 +817,35 @@ For production deployments:
 
 ### Token-Bound Database Configuration (New in v0.6.0)
 
-Create a `tokens.json` file for advanced token management with database 
binding:
+Managed token creation returns the bearer value once and writes only its digest
+to `tokens.json`. For manual provisioning, generate the bearer value and digest
+outside the repository, store the bearer value in the client secret store, and
+place only the digest in the server file:
+
+```bash
+python - <<'PY'
+import hashlib
+import secrets
+
+token = secrets.token_urlsafe(32)
+print(f"Bearer token (store once): {token}")
+print(f"token_digest: sha256:{hashlib.sha256(token.encode()).hexdigest()}")
+PY
+```
+
+The v2 file format uses the generated digest:
 
 ```json
 {
-  "version": "1.0",
+  "version": "2.0",
   "tokens": [
     {
       "token_id": "customer-a-token",
-      "token": "customer_a_secure_token_12345",
+      "token_digest": 
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+      "created_at": "2026-07-29T00:00:00Z",
+      "expires_at": null,
+      "last_used": null,
       "description": "Customer A dedicated database access",
-      "expires_hours": null,
       "is_active": true,
       "database_config": {
         "host": "customer-a-db.example.com",
@@ -836,27 +856,17 @@ Create a `tokens.json` file for advanced token management 
with database binding:
         "charset": "UTF8",
         "fe_http_port": 8030
       }
-    },
-    {
-      "token_id": "customer-b-token", 
-      "token": "customer_b_secure_token_67890",
-      "description": "Customer B dedicated database access",
-      "expires_hours": 720,
-      "is_active": true,
-      "database_config": {
-        "host": "customer-b-db.example.com",
-        "port": 9030,
-        "user": "customer_b_user", 
-        "password": "secure_password",
-        "database": "customer_b_data",
-        "charset": "UTF8",
-        "fe_http_port": 8030
-      }
     }
   ]
 }
 ```
 
+Replace the all-zero example with the generated digest; it is intentionally not
+a usable credential. Managed writes are atomic and set the file mode to `0600`.
+Version 1 files containing `token` plaintext are accepted once for migration,
+then immediately replaced with version 2 digest-only records. The server cannot
+recover or display the original bearer value from `token_digest`.
+
 ### Hot Reload Configuration Updates (New in v0.6.0)
 
 The system automatically detects and applies configuration changes:
@@ -1783,16 +1793,18 @@ cat logs/doris_mcp_server_critical.log
    TOKEN_FILE_PATH=tokens.json
    ```
 
-2. **Create tokens.json Configuration**:
+2. **Create the bearer token once and store only its digest in tokens.json**:
    ```json
    {
-     "version": "1.0",
+     "version": "2.0",
      "tokens": [
        {
          "token_id": "tenant-alpha",
-         "token": "<generated-deployment-token-at-least-32-characters>",
+         "token_digest": 
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+         "created_at": "2026-07-29T00:00:00Z",
+         "expires_at": null,
+         "last_used": null,
          "description": "Tenant Alpha database access",
-         "expires_hours": null,
          "is_active": true,
          "database_config": {
            "host": "tenant-alpha-db.company.com",
@@ -1806,6 +1818,10 @@ cat logs/doris_mcp_server_critical.log
      ]
    }
    ```
+   Generate the real bearer token and digest with the command in
+   [Token-Bound Database 
Configuration](#token-bound-database-configuration-new-in-v060).
+   The all-zero value above is deliberately unusable. Give the bearer value to
+   the client once; do not place it in the file.
 
 3. **Configuration Priority** (New in v0.6.0):
    - **Token-bound DB config** (highest priority)
@@ -1872,16 +1888,25 @@ cp tokens.json tokens.json.backup
 
 **Primary Token Management Method (Recommended):**
 ```bash
-# 1. Edit tokens.json file directly (safest method)
-nano tokens.json
-
-# 2. Hot reload will automatically detect changes
-# No server restart required - changes applied within 10 seconds
-
-# 3. Monitor hot reload in logs
+# 1. Keep the management endpoint disabled unless local administration is 
needed.
+# 2. When enabled, create a token through the protected localhost endpoint.
+curl -X POST \
+  -H "Authorization: Bearer $TOKEN_MANAGEMENT_ADMIN_TOKEN" \
+  -H "Content-Type: application/json" \
+  --data '{"token_id":"service-a","expires_hours":720}' \
+  http://127.0.0.1:3000/token/create
+
+# 3. Capture the returned bearer token once and place it in the client secret 
store.
+# 4. The server writes only token_digest to tokens.json.
+# 5. Monitor hot reload in logs.
 tail -f logs/doris_mcp_server_info.log | grep "hot reload"
 ```
 
+For deployments without HTTP token management, use a high-entropy `TOKEN_<ID>`
+environment secret or generate a bearer/digest pair offline as shown above.
+Manual `tokens.json` entries must use `token_digest`; plaintext `token` entries
+exist only for one-way migration from version 1.
+
 **Administrative Endpoints (Secure, Local Access Only):**
 
 🛡️ **SECURITY**: These endpoints are protected by comprehensive security 
controls and are **disabled by default**.
@@ -1912,18 +1937,22 @@ curl -H "Authorization: Bearer 
$TOKEN_MANAGEMENT_ADMIN_TOKEN" http://127.0.0.1:3
    ```json
    // tokens.json
    {
-     "version": "1.0",
+     "version": "2.0",
      "tokens": [
        {
          "token_id": "dev-token",
-         "token": "<generated-development-token-at-least-32-characters>",
+         "token_digest": 
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+         "created_at": "2026-07-29T00:00:00Z",
+         "expires_at": "2026-07-30T00:00:00Z",
+         "last_used": null,
          "description": "Development environment access",
-         "expires_hours": 24,
          "is_active": true
        }
      ]
    }
    ```
+   Replace the all-zero digest with one derived from a securely generated
+   bearer token, and keep that bearer token outside the server file.
 
 2. **Production Deployment**:
    ```bash
@@ -1936,19 +1965,25 @@ curl -H "Authorization: Bearer 
$TOKEN_MANAGEMENT_ADMIN_TOKEN" http://127.0.0.1:3
    ```
 
 **Security Features:**
-- **File-Based Management**: Primary management through secured configuration 
files
+- **Digest-Only Persistence**: Bearer plaintext is returned only when created;
+  version 2 files contain self-describing SHA-256/SHA-512 digests
+- **Atomic File Management**: Managed writes use same-directory replacement and
+  force owner-only `0600` permissions
 - **Hot Reload**: Automatic configuration updates without service interruption
-- **Token Hashing**: Tokens stored as SHA-256 hashes internally
+- **Legacy Migration**: Version 1 plaintext entries are replaced by digest-only
+  records on first successful load
 - **Audit Trail**: Complete logging of all token operations and changes
 - **Expiration Management**: Automatic cleanup of expired tokens
 - **Local Admin Only**: Management endpoints restricted to localhost access
 - **Configuration Validation**: Immediate validation of token and database 
configurations
 
 **Security Best Practices:**
-- Always manage tokens through secure configuration files
+- Store bearer values in client-side secret management; keep only digests on
+  the server
 - Never expose token management endpoints to external networks
 - Use strong, randomly generated tokens for production
-- Implement proper file permissions for tokens.json (600 or 640)
+- Keep manually managed `tokens.json` files owner-readable only; managed writes
+  enforce `0600`
 - Regular audit of active tokens and their usage patterns
 - Monitor hot reload logs for unauthorized configuration changes
 
diff --git a/doris_mcp_server/auth/token_manager.py 
b/doris_mcp_server/auth/token_manager.py
index bbd11a4..32f55ec 100644
--- a/doris_mcp_server/auth/token_manager.py
+++ b/doris_mcp_server/auth/token_manager.py
@@ -22,20 +22,22 @@ Provides enterprise-grade token authentication system with 
configurable tokens,
 expiration management, role-based access control and secure token storage.
 """
 
-import hashlib
+import asyncio
 import json
 import os
 import secrets
-import time
-import asyncio
+import tempfile
 from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from typing import Dict, List, Optional, Any
 from pathlib import Path
 
 from ..utils.logger import get_logger
 from ..utils.secret_policy import (
+    build_token_digest,
     is_static_token_environment_variable,
+    normalize_token_digest,
+    normalize_token_hash_algorithm,
     validate_high_entropy_secret,
 )
 from ..utils.security import RESERVED_DORIS_OAUTH_TOKEN_PREFIX, SecurityLevel
@@ -87,25 +89,28 @@ class TokenManager:
     - Token lifecycle management
     """
     
-    def __init__(self, config):
+    def __init__(self, config: Any) -> None:
         self.config = config
         self.logger = get_logger(__name__)
         
         # Token storage
         self._tokens: Dict[str, TokenInfo] = {}  # token_hash -> TokenInfo
         self._token_ids: Dict[str, str] = {}     # token_id -> token_hash
+        self._digest_algorithms: set[str] = set()
         
         # Configuration
         self.token_file_path = getattr(config.security, 'token_file_path', 
'tokens.json')
         self.enable_token_expiry = getattr(config.security, 
'enable_token_expiry', True)
         self.default_token_expiry_hours = getattr(config.security, 
'default_token_expiry_hours', 24 * 30)  # 30 days
-        self.token_hash_algorithm = getattr(config.security, 
'token_hash_algorithm', 'sha256')
+        self.token_hash_algorithm = normalize_token_hash_algorithm(
+            getattr(config.security, 'token_hash_algorithm', 'sha256')
+        )
         
         # Hot reload configuration
         self.enable_hot_reload = True
         self.hot_reload_interval = 10  # Check every 10 seconds
-        self._file_last_modified = 0
-        self._hot_reload_task = None
+        self._file_last_modified = 0.0
+        self._hot_reload_task: Optional[asyncio.Task[None]] = None
         
         # Load tokens from configuration
         self._load_tokens()
@@ -130,22 +135,62 @@ class TokenManager:
         
         self.logger.info(f"TokenManager initialized with {len(self._tokens)} 
tokens, hot reload: {self.enable_hot_reload}")
 
-    def _validate_static_token_prefix(self, token: str):
+    def _validate_static_token_prefix(self, token: str) -> None:
         if token.startswith(RESERVED_DORIS_OAUTH_TOKEN_PREFIX):
             raise ValueError(
                 f"Static tokens cannot use reserved Doris OAuth prefix "
                 f"'{RESERVED_DORIS_OAUTH_TOKEN_PREFIX}'"
             )
-    
-    def _add_token_from_config(self, token_config: Dict[str, Any]):
+
+    @staticmethod
+    def _parse_datetime(value: Any, *, setting: str) -> Optional[datetime]:
+        """Parse an RFC 3339 timestamp into the manager's naive UTC form."""
+        if value in (None, ""):
+            return None
+        if not isinstance(value, str):
+            raise ValueError(f"{setting} must be an RFC 3339 timestamp")
+        normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
+        try:
+            parsed = datetime.fromisoformat(normalized)
+        except ValueError as exc:
+            raise ValueError(f"{setting} must be an RFC 3339 timestamp") from 
exc
+        if parsed.tzinfo is not None:
+            parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+        return parsed
+
+    def _add_token_from_config(
+        self,
+        token_config: Dict[str, Any],
+    ) -> tuple[str, TokenInfo, bool]:
         """Add token from configuration with optional database binding"""
         try:
+            token_id = str(token_config.get('token_id') or '').strip()
+            if not token_id:
+                raise ValueError("Static token entry requires token_id")
+
+            created_at = self._parse_datetime(
+                token_config.get('created_at'),
+                setting=f"static token '{token_id}' created_at",
+            ) or datetime.utcnow()
+
             # Calculate expiration time
-            expires_at = None
-            if self.enable_token_expiry:
+            if 'expires_at' in token_config:
+                expires_at = self._parse_datetime(
+                    token_config.get('expires_at'),
+                    setting=f"static token '{token_id}' expires_at",
+                )
+            elif self.enable_token_expiry:
+                expires_at = None
                 expires_hours = token_config.get('expires_hours', 
self.default_token_expiry_hours)
                 if expires_hours is not None:
-                    expires_at = datetime.utcnow() + 
timedelta(hours=expires_hours)
+                    expires_at = created_at + timedelta(hours=expires_hours)
+            else:
+                expires_at = None
+
+            last_used = self._parse_datetime(
+                token_config.get('last_used'),
+                setting=f"static token '{token_id}' last_used",
+            )
             
             # Parse database configuration if provided
             database_config = None
@@ -163,21 +208,44 @@ class TokenManager:
             
             # Create token info
             token_info = TokenInfo(
-                token_id=token_config['token_id'],
+                token_id=token_id,
+                created_at=created_at,
                 expires_at=expires_at,
+                last_used=last_used,
                 description=token_config.get('description', ''),
                 is_active=token_config.get('is_active', True),
                 database_config=database_config
             )
-            
-            # Hash the token
-            raw_token = token_config['token']
-            self._validate_static_token_prefix(raw_token)
-            validate_high_entropy_secret(
-                raw_token,
-                setting=f"static token '{token_info.token_id}'",
-            )
-            token_hash = self._hash_token(raw_token)
+
+            has_raw_token = 'token' in token_config
+            has_token_digest = 'token_digest' in token_config
+            if has_raw_token == has_token_digest:
+                raise ValueError(
+                    f"static token '{token_id}' must contain exactly one of "
+                    "token or token_digest"
+                )
+
+            if has_token_digest:
+                stored_digest = token_config.get('token_digest')
+                if not isinstance(stored_digest, str):
+                    raise ValueError(
+                        f"static token '{token_id}' token_digest is required"
+                    )
+                token_hash = normalize_token_digest(
+                    stored_digest,
+                    setting=f"static token '{token_id}' token_digest",
+                )
+            else:
+                raw_token = token_config.get('token')
+                if not isinstance(raw_token, str):
+                    raise ValueError(f"static token '{token_id}' is required")
+                self._validate_static_token_prefix(raw_token)
+                validate_high_entropy_secret(
+                    raw_token,
+                    setting=f"static token '{token_info.token_id}'",
+                )
+                token_hash = self._hash_token(raw_token)
+            self._digest_algorithms.add(token_hash.partition(":")[0])
             
             # Store token
             self._tokens[token_hash] = token_info
@@ -185,12 +253,13 @@ class TokenManager:
             
             db_info = f" with DB binding ({database_config.host})" if 
database_config else ""
             self.logger.debug(f"Added token '{token_info.token_id}'{db_info}")
-            
+            return token_hash, token_info, has_raw_token
+
         except Exception as e:
             self.logger.error(f"Failed to add token from config: {e}")
             raise
     
-    def _load_tokens(self):
+    def _load_tokens(self) -> None:
         """Load tokens from configuration sources"""
         # 1. Load from environment variables
         self._load_tokens_from_env()
@@ -201,7 +270,7 @@ class TokenManager:
         
         self.logger.info(f"Token loading completed, total tokens: 
{len(self._tokens)}")
     
-    def _load_tokens_from_env(self):
+    def _load_tokens_from_env(self) -> None:
         """Load tokens from environment variables
         
         Simplified format: 
@@ -249,7 +318,7 @@ class TokenManager:
             except Exception as e:
                 self.logger.error(f"Failed to load token {token_id} from 
environment: {e}")
     
-    def _load_tokens_from_file(self):
+    def _load_tokens_from_file(self) -> None:
         """Load tokens from JSON file"""
         try:
             with open(self.token_file_path, 'r', encoding='utf-8') as f:
@@ -262,10 +331,42 @@ class TokenManager:
             else:
                 self.logger.error(f"Invalid token file format: 
{self.token_file_path}")
                 return
-            
+            if not isinstance(tokens_list, list):
+                raise ValueError("Static token file must contain a tokens 
array")
+
+            persisted_tokens = []
+            needs_migration = (
+                not isinstance(tokens_data, dict)
+                or tokens_data.get("version") != "2.0"
+            )
             for token_config in tokens_list:
-                self._add_token_from_config(token_config)
-            
+                if not isinstance(token_config, dict):
+                    raise ValueError("Static token file entries must be 
objects")
+                token_hash, token_info, had_raw_token = 
self._add_token_from_config(
+                    token_config
+                )
+                persisted_tokens.append(
+                    self._token_info_to_config(token_hash, token_info)
+                )
+                needs_migration = needs_migration or had_raw_token
+
+            if needs_migration:
+                migrated_data = self._token_file_document(persisted_tokens)
+                try:
+                    self._atomic_write_token_file(
+                        Path(self.token_file_path),
+                        migrated_data,
+                    )
+                except Exception as exc:
+                    raise ValueError(
+                        "Unable to migrate the static token file to 
digest-only "
+                        "storage"
+                    ) from exc
+                self.logger.info(
+                    "Migrated %s static token records to digest-only storage",
+                    len(persisted_tokens),
+                )
+
             self.logger.info(f"Loaded {len(tokens_list)} tokens from file: 
{self.token_file_path}")
             
         except ValueError:
@@ -273,24 +374,25 @@ class TokenManager:
         except Exception as e:
             self.logger.error(f"Failed to load tokens from file 
{self.token_file_path}: {e}")
     
-    def _hash_token(self, token: str) -> str:
+    def _hash_token(self, token: str, algorithm: Optional[str] = None) -> str:
         """Hash token for secure storage"""
-        if self.token_hash_algorithm == 'sha256':
-            return hashlib.sha256(token.encode('utf-8')).hexdigest()
-        elif self.token_hash_algorithm == 'sha512':
-            return hashlib.sha512(token.encode('utf-8')).hexdigest()
-        else:
-            # Fallback to sha256
-            return hashlib.sha256(token.encode('utf-8')).hexdigest()
-    
+        return build_token_digest(token, algorithm or 
self.token_hash_algorithm)
+
+    def _lookup_token(self, token: str) -> tuple[str, Optional[TokenInfo]]:
+        """Look up a raw token against every digest algorithm in the store."""
+        algorithms = self._digest_algorithms or {self.token_hash_algorithm}
+        for algorithm in sorted(algorithms):
+            token_hash = self._hash_token(token, algorithm)
+            token_info = self._tokens.get(token_hash)
+            if token_info is not None:
+                return token_hash, token_info
+        return "", None
+
     async def validate_token(self, token: str) -> TokenValidationResult:
         """Validate token and return user information"""
         try:
-            # Hash the provided token
-            token_hash = self._hash_token(token)
-            
             # Find token info
-            token_info = self._tokens.get(token_hash)
+            _token_hash, token_info = self._lookup_token(token)
             if not token_info:
                 return TokenValidationResult(
                     is_valid=False,
@@ -372,13 +474,24 @@ class TokenManager:
             
             # Hash and store token
             token_hash = self._hash_token(raw_token)
+            if token_hash in self._tokens:
+                raise ValueError("Token value already exists")
             self._tokens[token_hash] = token_info
             self._token_ids[token_id] = token_hash
+            self._digest_algorithms.add(self.token_hash_algorithm)
             
             self.logger.info(f"Created new token '{token_id}'")
             
             # Save token to file
-            self._save_token_to_file(token_id, raw_token, token_info)
+            try:
+                self._save_token_to_file(token_id, token_hash, token_info)
+            except Exception:
+                self._tokens.pop(token_hash, None)
+                self._token_ids.pop(token_id, None)
+                self._digest_algorithms = {
+                    digest.partition(":")[0] for digest in self._tokens
+                }
+                raise
             
             return raw_token
             
@@ -393,106 +506,57 @@ class TokenManager:
                 self.logger.warning(f"Token ID '{token_id}' not found")
                 return False
             
-            # Get token hash and remove from storage
+            # Persist the revocation before changing live state. Otherwise a
+            # failed file write would let the token reappear after restart.
             token_hash = self._token_ids[token_id]
+            self._remove_token_from_file(token_id)
+
             if token_hash in self._tokens:
                 del self._tokens[token_hash]
             del self._token_ids[token_id]
             
             self.logger.info(f"Revoked token '{token_id}'")
-            
-            # Save updated tokens to file
-            self._remove_token_from_file(token_id)
-            
             return True
             
         except Exception as e:
             self.logger.error(f"Failed to revoke token '{token_id}': {e}")
             return False
     
-    def _save_tokens_to_file(self):
+    def _save_tokens_to_file(self) -> None:
         """Save current tokens to JSON file"""
         try:
-            # Convert current tokens to file format
-            tokens_list = []
-            
-            for token_hash, token_info in self._tokens.items():
-                # Find the raw token for this token_info
-                raw_token = None
-                for tid, thash in self._token_ids.items():
-                    if thash == token_hash and tid == token_info.token_id:
-                        # We can't recover the original token from hash, 
-                        # so we'll create a placeholder for existing tokens
-                        raw_token = f"<existing_token_hash_{token_hash[:8]}>"
-                        break
-                
-                if raw_token is None:
-                    continue
-                
-                token_config = {
-                    "token_id": token_info.token_id,
-                    "token": raw_token,
-                    "description": token_info.description,
-                    "expires_hours": None,
-                    "is_active": token_info.is_active
-                }
-                
-                # Add expiration info
-                if token_info.expires_at:
-                    # Calculate remaining hours from now
-                    remaining = token_info.expires_at - datetime.utcnow()
-                    if remaining.total_seconds() > 0:
-                        token_config["expires_hours"] = 
int(remaining.total_seconds() / 3600)
-                    else:
-                        token_config["expires_hours"] = 0
-                
-                # Add database config if present
-                if token_info.database_config:
-                    token_config["database_config"] = {
-                        "host": token_info.database_config.host,
-                        "port": token_info.database_config.port,
-                        "user": token_info.database_config.user,
-                        "password": token_info.database_config.password,
-                        "database": token_info.database_config.database,
-                        "charset": token_info.database_config.charset,
-                        "fe_http_port": token_info.database_config.fe_http_port
-                    }
-                
-                tokens_list.append(token_config)
-            
-            # Create file content
-            file_content = {
-                "version": "1.0",
-                "description": "Doris MCP Server Token configuration file",
-                "created_at": datetime.utcnow().isoformat() + "Z",
-                "tokens": tokens_list,
-                "notes": [
-                    "This file is automatically updated when tokens are 
created or revoked",
-                    "Please backup this file before making manual changes",
-                    "Tokens with hash placeholders were loaded from previous 
configuration"
-                ]
-            }
-            
-            # Save to file
-            with open(self.token_file_path, 'w', encoding='utf-8') as f:
-                json.dump(file_content, f, indent=2, ensure_ascii=False)
-            
+            tokens_list = [
+                self._token_info_to_config(token_hash, token_info)
+                for token_hash, token_info in self._tokens.items()
+            ]
+            file_content = self._token_file_document(tokens_list)
+            self._atomic_write_token_file(
+                Path(self.token_file_path),
+                file_content,
+            )
             self.logger.info(f"Saved {len(tokens_list)} tokens to file: 
{self.token_file_path}")
             
         except Exception as e:
             self.logger.error(f"Failed to save tokens to file 
{self.token_file_path}: {e}")
     
-    def _save_token_to_file(self, token_id: str, raw_token: str, token_info: 
TokenInfo):
-        """Save a single new token to file (for newly created tokens only)"""
+    def _save_token_to_file(
+        self,
+        token_id: str,
+        token_hash: str,
+        token_info: TokenInfo,
+    ) -> None:
+        """Save one new token as a digest-only record."""
         try:
             # Load existing file
-            existing_data = {"tokens": []}
+            existing_data: Dict[str, Any] = {"tokens": []}
             if os.path.exists(self.token_file_path):
                 try:
                     with open(self.token_file_path, 'r', encoding='utf-8') as 
f:
                         existing_data = json.load(f)
                 except Exception as e:
-                    self.logger.warning(f"Could not load existing token file: 
{e}")
+                    raise ValueError(
+                        f"Could not load existing token file: {e}"
+                    ) from e
             
             # Ensure tokens list exists
             if 'tokens' not in existing_data or not 
isinstance(existing_data['tokens'], list):
@@ -503,47 +567,157 @@ class TokenManager:
             for i, token_config in enumerate(existing_data['tokens']):
                 if token_config.get('token_id') == token_id:
                     # Update existing token
-                    existing_data['tokens'][i] = 
self._token_info_to_config(token_id, raw_token, token_info)
+                    existing_data['tokens'][i] = self._token_info_to_config(
+                        token_hash,
+                        token_info,
+                    )
                     token_exists = True
                     break
             
             # Add new token if it doesn't exist
             if not token_exists:
-                new_token_config = self._token_info_to_config(token_id, 
raw_token, token_info)
+                new_token_config = self._token_info_to_config(
+                    token_hash,
+                    token_info,
+                )
                 existing_data['tokens'].append(new_token_config)
-            
+
+            existing_data['tokens'] = [
+                self._sanitize_persisted_token(token_config)
+                for token_config in existing_data['tokens']
+            ]
+
             # Update metadata
-            existing_data.update({
-                "version": "1.0",
-                "description": "Doris MCP Server Token configuration file",
-                "updated_at": datetime.utcnow().isoformat() + "Z"
-            })
-            
-            # Save to file
-            with open(self.token_file_path, 'w', encoding='utf-8') as f:
-                json.dump(existing_data, f, indent=2, ensure_ascii=False)
-            
+            existing_data = self._token_file_document(existing_data['tokens'])
+            self._atomic_write_token_file(
+                Path(self.token_file_path),
+                existing_data,
+            )
             self.logger.info(f"Saved token '{token_id}' to file: 
{self.token_file_path}")
             
         except Exception as e:
             self.logger.error(f"Failed to save token '{token_id}' to file: 
{e}")
+            raise
     
-    def _token_info_to_config(self, token_id: str, raw_token: str, token_info: 
TokenInfo) -> dict:
-        """Convert TokenInfo to file configuration format"""
-        token_config = {
-            "token_id": token_id,
-            "token": raw_token,
+    def _token_file_document(self, tokens: List[Dict[str, Any]]) -> Dict[str, 
Any]:
+        """Build the versioned digest-only token file document."""
+        return {
+            "version": "2.0",
+            "description": "Doris MCP Server digest-only token configuration 
file",
+            "updated_at": datetime.utcnow().isoformat() + "Z",
+            "tokens": tokens,
+            "notes": [
+                "Bearer token plaintext is returned only when a token is 
created.",
+                "token_digest is self-describing and may use sha256 or 
sha512.",
+                "Do not replace token_digest with a plaintext token.",
+            ],
+        }
+
+    def _atomic_write_token_file(
+        self,
+        file_path: Path,
+        file_content: Dict[str, Any],
+    ) -> None:
+        """Atomically replace a token file with owner-only permissions."""
+        temporary_path: Optional[Path] = None
+        try:
+            with tempfile.NamedTemporaryFile(
+                mode="w",
+                encoding="utf-8",
+                dir=file_path.parent,
+                prefix=f".{file_path.name}.",
+                suffix=".tmp",
+                delete=False,
+            ) as temporary_file:
+                temporary_path = Path(temporary_file.name)
+                os.chmod(temporary_path, 0o600)
+                json.dump(file_content, temporary_file, indent=2, 
ensure_ascii=False)
+                temporary_file.write("\n")
+                temporary_file.flush()
+                os.fsync(temporary_file.fileno())
+            os.replace(temporary_path, file_path)
+            os.chmod(file_path, 0o600)
+            if file_path == Path(self.token_file_path):
+                self._update_file_modified_time()
+        except Exception:
+            if temporary_path is not None:
+                temporary_path.unlink(missing_ok=True)
+            raise
+
+    def _sanitize_persisted_token(
+        self,
+        token_config: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        """Remove legacy plaintext while preserving a token record's 
metadata."""
+        token_id = str(token_config.get("token_id") or "").strip()
+        if not token_id:
+            raise ValueError("Static token entry requires token_id")
+        has_raw_token = "token" in token_config
+        has_token_digest = "token_digest" in token_config
+        if has_raw_token == has_token_digest:
+            raise ValueError(
+                f"static token '{token_id}' must contain exactly one of "
+                "token or token_digest"
+            )
+
+        sanitized = {
+            key: token_config[key]
+            for key in (
+                "created_at",
+                "expires_at",
+                "expires_hours",
+                "last_used",
+                "description",
+                "is_active",
+                "database_config",
+            )
+            if key in token_config
+        }
+        sanitized["token_id"] = token_id
+        if has_raw_token:
+            raw_token = token_config["token"]
+            if not isinstance(raw_token, str):
+                raise ValueError(f"static token '{token_id}' is required")
+            self._validate_static_token_prefix(raw_token)
+            validate_high_entropy_secret(
+                raw_token,
+                setting=f"static token '{token_id}'",
+            )
+            sanitized["token_digest"] = self._hash_token(raw_token)
+        else:
+            sanitized["token_digest"] = normalize_token_digest(
+                token_config["token_digest"],
+                setting=f"static token '{token_id}' token_digest",
+            )
+        return sanitized
+
+    def _token_info_to_config(
+        self,
+        token_hash: str,
+        token_info: TokenInfo,
+    ) -> Dict[str, Any]:
+        """Convert TokenInfo to a digest-only file record."""
+        token_config: Dict[str, Any] = {
+            "token_id": token_info.token_id,
+            "token_digest": normalize_token_digest(
+                token_hash,
+                setting=f"static token '{token_info.token_id}' token_digest",
+            ),
+            "created_at": token_info.created_at.isoformat() + "Z",
+            "expires_at": (
+                token_info.expires_at.isoformat() + "Z"
+                if token_info.expires_at
+                else None
+            ),
+            "last_used": (
+                token_info.last_used.isoformat() + "Z"
+                if token_info.last_used
+                else None
+            ),
             "description": token_info.description,
-            "expires_hours": None,
             "is_active": token_info.is_active
         }
-        
-        # Add expiration info
-        if token_info.expires_at:
-            # Calculate remaining hours from creation time
-            remaining = token_info.expires_at - token_info.created_at
-            token_config["expires_hours"] = int(remaining.total_seconds() / 
3600) if remaining.total_seconds() > 0 else None
-        
+
         # Add database config if present
         if token_info.database_config:
             token_config["database_config"] = {
@@ -558,7 +732,7 @@ class TokenManager:
         
         return token_config
     
-    def _remove_token_from_file(self, token_id: str):
+    def _remove_token_from_file(self, token_id: str) -> None:
         """Remove a token from the JSON file"""
         try:
             if not os.path.exists(self.token_file_path):
@@ -574,33 +748,30 @@ class TokenManager:
             # Remove the token
             original_count = len(existing_data['tokens'])
             existing_data['tokens'] = [
-                token for token in existing_data['tokens'] 
+                self._sanitize_persisted_token(token)
+                for token in existing_data['tokens']
                 if token.get('token_id') != token_id
             ]
             
             if len(existing_data['tokens']) < original_count:
                 # Update metadata
-                existing_data.update({
-                    "version": "1.0", 
-                    "description": "Doris MCP Server Token configuration file",
-                    "updated_at": datetime.utcnow().isoformat() + "Z"
-                })
-                
-                # Save to file
-                with open(self.token_file_path, 'w', encoding='utf-8') as f:
-                    json.dump(existing_data, f, indent=2, ensure_ascii=False)
-                
+                existing_data = 
self._token_file_document(existing_data['tokens'])
+                self._atomic_write_token_file(
+                    Path(self.token_file_path),
+                    existing_data,
+                )
                 self.logger.info(f"Removed token '{token_id}' from file: 
{self.token_file_path}")
             
         except Exception as e:
             self.logger.error(f"Failed to remove token '{token_id}' from file: 
{e}")
+            raise
     
     async def list_tokens(self) -> List[Dict[str, Any]]:
         """List all tokens (without sensitive data)"""
         tokens = []
         
         for token_hash, token_info in self._tokens.items():
-            token_data = {
+            token_data: Dict[str, Any] = {
                 'token_id': token_info.token_id,
                 'created_at': token_info.created_at.isoformat(),
                 'expires_at': token_info.expires_at.isoformat() if 
token_info.expires_at else None,
@@ -625,7 +796,7 @@ class TokenManager:
             tokens.append(token_data)
         
         # Sort by creation time
-        tokens.sort(key=lambda x: x['created_at'], reverse=True)
+        tokens.sort(key=lambda item: str(item['created_at']), reverse=True)
         
         return tokens
     
@@ -656,19 +827,16 @@ class TokenManager:
     async def save_tokens_to_file(self, file_path: Optional[str] = None) -> 
bool:
         """Save current tokens to JSON file"""
         try:
-            file_path = file_path or self.token_file_path
-            tokens_list = await self.list_tokens()
-            
-            tokens_data = {
-                'version': '1.0',
-                'created_at': datetime.utcnow().isoformat(),
-                'tokens': tokens_list
-            }
-            
-            with open(file_path, 'w', encoding='utf-8') as f:
-                json.dump(tokens_data, f, indent=2, ensure_ascii=False)
-            
-            self.logger.info(f"Saved {len(tokens_list)} tokens to file: 
{file_path}")
+            target_path = Path(file_path or self.token_file_path)
+            tokens_list = [
+                self._token_info_to_config(token_hash, token_info)
+                for token_hash, token_info in self._tokens.items()
+            ]
+            self._atomic_write_token_file(
+                target_path,
+                self._token_file_document(tokens_list),
+            )
+            self.logger.info(f"Saved {len(tokens_list)} tokens to file: 
{target_path}")
             return True
             
         except Exception as e:
@@ -685,8 +853,7 @@ class TokenManager:
             DatabaseConfig if token exists and has database binding, None 
otherwise
         """
         try:
-            token_hash = self._hash_token(token)
-            token_info = self._tokens.get(token_hash)
+            _token_hash, token_info = self._lookup_token(token)
             
             if not token_info or not token_info.is_active:
                 return None
@@ -722,7 +889,7 @@ class TokenManager:
             'last_file_check': 
datetime.fromtimestamp(self._file_last_modified).isoformat() if 
self._file_last_modified else None
         }
     
-    def _start_hot_reload(self):
+    def _start_hot_reload(self) -> None:
         """Start hot reload monitoring task"""
         if self._hot_reload_task:
             return  # Already running
@@ -734,14 +901,14 @@ class TokenManager:
         self._hot_reload_task = asyncio.create_task(self._hot_reload_monitor())
         self.logger.info(f"Started hot reload monitoring for 
{self.token_file_path}")
     
-    def stop_hot_reload(self):
+    def stop_hot_reload(self) -> None:
         """Stop hot reload monitoring"""
         if self._hot_reload_task:
             self._hot_reload_task.cancel()
             self._hot_reload_task = None
             self.logger.info("Stopped hot reload monitoring")
     
-    def _update_file_modified_time(self):
+    def _update_file_modified_time(self) -> None:
         """Update the last modified time of tokens file"""
         try:
             if os.path.exists(self.token_file_path):
@@ -749,7 +916,7 @@ class TokenManager:
         except Exception as e:
             self.logger.debug(f"Failed to get file modification time: {e}")
     
-    async def _hot_reload_monitor(self):
+    async def _hot_reload_monitor(self) -> None:
         """Background task to monitor tokens.json file changes"""
         while True:
             try:
@@ -767,16 +934,19 @@ class TokenManager:
                         # Backup current tokens
                         old_tokens = self._tokens.copy()
                         old_token_ids = self._token_ids.copy()
+                        old_digest_algorithms = self._digest_algorithms.copy()
                         
                         # Clear and reload
                         self._tokens.clear()
                         self._token_ids.clear()
+                        self._digest_algorithms.clear()
                         
-                        # Load from file
+                        # Environment credentials remain effective across file 
reloads.
+                        self._load_tokens_from_env()
                         self._load_tokens_from_file()
                         
                         # Update modification time
-                        self._file_last_modified = current_mtime
+                        self._update_file_modified_time()
                         
                         self.logger.info(f"Hot reload completed, 
{len(self._tokens)} tokens loaded")
                         
@@ -785,6 +955,7 @@ class TokenManager:
                         self.logger.error(f"Hot reload failed, restoring 
previous tokens: {reload_error}")
                         self._tokens = old_tokens
                         self._token_ids = old_token_ids
+                        self._digest_algorithms = old_digest_algorithms
                 
             except asyncio.CancelledError:
                 self.logger.info("Hot reload monitor stopped")
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index e49a6df..001f6b5 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -40,6 +40,8 @@ except ImportError:
 from .logger import get_logger
 from .secret_policy import (
     is_static_token_environment_variable,
+    normalize_token_digest,
+    normalize_token_hash_algorithm,
     validate_high_entropy_secret,
 )
 
@@ -1464,12 +1466,12 @@ def build_auth_config_inputs(config: DorisConfig, 
requested_workers: int | None
 
 def _configured_static_tokens(
     security_config: SecurityConfig,
-) -> list[tuple[str, Any, bool]]:
-    """Load source labels, raw values, and active flags for configured static 
tokens."""
-    configured: list[tuple[str, Any, bool]] = []
+) -> list[tuple[str, Any, bool, bool]]:
+    """Load source labels, credentials, active flags, and digest markers."""
+    configured: list[tuple[str, Any, bool, bool]] = []
     for name, value in os.environ.items():
         if is_static_token_environment_variable(name):
-            configured.append((name, value, True))
+            configured.append((name, value, True, False))
 
     token_file = Path(security_config.token_file_path)
     if not token_file.exists():
@@ -1503,12 +1505,24 @@ def _configured_static_tokens(
             raise AuthConfigError(
                 f"Static token file {token_file} entry {index} requires 
token_id"
             )
-        raw_token = token_entry.get("token")
+        has_raw_token = "token" in token_entry
+        has_token_digest = "token_digest" in token_entry
+        if has_raw_token == has_token_digest:
+            raise AuthConfigError(
+                f"Static token file {token_file} entry {token_id} must contain 
"
+                "exactly one of token or token_digest"
+            )
+        credential = (
+            token_entry.get("token_digest")
+            if has_token_digest
+            else token_entry.get("token")
+        )
         configured.append(
             (
                 f"{token_file}:{token_id}",
-                raw_token,
+                credential,
                 _str_to_bool(token_entry.get("is_active", True)),
+                has_token_digest,
             )
         )
     return configured
@@ -1517,12 +1531,18 @@ def _configured_static_tokens(
 def _validate_static_token_bootstrap(config: DorisConfig) -> None:
     """Require at least one active high-entropy token when static auth is 
enabled."""
     configured = _configured_static_tokens(config.security)
-    for setting, raw_token, _active in configured:
+    for setting, credential, _active, is_digest in configured:
         try:
-            validate_high_entropy_secret(raw_token, setting=setting)
+            if is_digest:
+                normalize_token_digest(
+                    credential,
+                    setting=f"{setting} token_digest",
+                )
+            else:
+                validate_high_entropy_secret(credential, setting=setting)
         except ValueError as exc:
             raise AuthConfigError(str(exc)) from exc
-    if not any(active for _setting, _raw_token, active in configured):
+    if not any(active for _setting, _credential, active, _is_digest in 
configured):
         raise AuthConfigError(
             "Token authentication requires at least one active high-entropy 
credential. "
             "Set TOKEN_<ID> or populate TOKEN_FILE_PATH before enabling it."
@@ -1540,6 +1560,12 @@ def normalize_effective_auth_config(
     auth_type = str(inputs.legacy_auth_type.value or "").strip().lower()
     if auth_type and auth_type not in {"token", "basic", "oauth", "jwt"}:
         raise AuthConfigError(f"Unsupported AUTH_TYPE: {auth_type}")
+    try:
+        config.security.token_hash_algorithm = normalize_token_hash_algorithm(
+            config.security.token_hash_algorithm
+        )
+    except ValueError as exc:
+        raise AuthConfigError(str(exc)) from exc
 
     modern_auth_explicit = any(
         [
diff --git a/doris_mcp_server/utils/secret_policy.py 
b/doris_mcp_server/utils/secret_policy.py
index 0bdc850..60a3b76 100644
--- a/doris_mcp_server/utils/secret_policy.py
+++ b/doris_mcp_server/utils/secret_policy.py
@@ -17,8 +17,14 @@
 # under the License.
 """Shared policy for operator-provided authentication secrets."""
 
+import hashlib
+
 MIN_SECRET_LENGTH = 32
 MIN_DISTINCT_CHARACTERS = 10
+TOKEN_DIGEST_ALGORITHMS = {
+    "sha256": 64,
+    "sha512": 128,
+}
 
 _INSECURE_MARKERS = (
     "123456",
@@ -74,6 +80,51 @@ def validate_high_entropy_secret(value: str, *, setting: 
str) -> str:
     return value
 
 
+def normalize_token_hash_algorithm(
+    value: str,
+    *,
+    setting: str = "TOKEN_HASH_ALGORITHM",
+) -> str:
+    """Return a supported token digest algorithm name."""
+    algorithm = str(value or "").strip().lower()
+    if algorithm not in TOKEN_DIGEST_ALGORITHMS:
+        supported = ", ".join(sorted(TOKEN_DIGEST_ALGORITHMS))
+        raise ValueError(f"{setting} must be one of: {supported}")
+    return algorithm
+
+
+def build_token_digest(value: str, algorithm: str) -> str:
+    """Build a self-describing digest for a high-entropy bearer token."""
+    normalized_algorithm = normalize_token_hash_algorithm(algorithm)
+    digest = hashlib.new(normalized_algorithm, 
value.encode("utf-8")).hexdigest()
+    return f"{normalized_algorithm}:{digest}"
+
+
+def normalize_token_digest(value: str, *, setting: str) -> str:
+    """Validate and normalize a persisted self-describing token digest."""
+    if not isinstance(value, str) or not value:
+        raise ValueError(f"{setting} is required")
+    if value != value.strip():
+        raise ValueError(f"{setting} must not contain leading or trailing 
whitespace")
+
+    algorithm, separator, digest = value.partition(":")
+    algorithm = normalize_token_hash_algorithm(
+        algorithm,
+        setting=f"{setting} algorithm",
+    )
+    expected_length = TOKEN_DIGEST_ALGORITHMS[algorithm]
+    if (
+        separator != ":"
+        or len(digest) != expected_length
+        or any(character not in "0123456789abcdefABCDEF" for character in 
digest)
+    ):
+        raise ValueError(
+            f"{setting} must use {algorithm}: followed by "
+            f"{expected_length} hexadecimal characters"
+        )
+    return f"{algorithm}:{digest.lower()}"
+
+
 def is_static_token_environment_variable(name: str) -> bool:
     """Return whether an environment variable defines a static bearer token."""
     if not name.startswith("TOKEN_"):
diff --git a/test/security/test_token_digest_storage.py 
b/test/security/test_token_digest_storage.py
new file mode 100644
index 0000000..071f579
--- /dev/null
+++ b/test/security/test_token_digest_storage.py
@@ -0,0 +1,340 @@
+# Licensed to the 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.  The 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.
+
+import json
+import os
+import stat
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+
+from doris_mcp_server.auth.token_manager import TokenManager
+from doris_mcp_server.utils.config import (
+    AuthConfigError,
+    DorisConfig,
+    _mark_source,
+    normalize_effective_auth_config,
+)
+from doris_mcp_server.utils.secret_policy import (
+    build_token_digest,
+    is_static_token_environment_variable,
+)
+
+STATIC_TOKEN = "V4nK8qR2mT7xP5cL9sD3hF6jY1uB0eG4iW8aN2zQ"
+
+
+def _config(tmp_path: Path, *, algorithm: str = "sha256") -> DorisConfig:
+    config = DorisConfig()
+    config.security.token_file_path = str(tmp_path / "tokens.json")
+    config.security.token_hash_algorithm = algorithm
+    return config
+
+
+def _clear_static_token_environment(monkeypatch: pytest.MonkeyPatch) -> None:
+    for name in list(os.environ):
+        if is_static_token_environment_variable(name):
+            monkeypatch.delenv(name, raising=False)
+
+
[email protected]
+async def test_created_token_is_returned_once_and_persisted_as_digest(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    config = _config(tmp_path)
+    manager = TokenManager(config)
+    try:
+        raw_token = await manager.create_token(
+            "generated",
+            expires_hours=24,
+            description="one-time display",
+        )
+        token_path = Path(config.security.token_file_path)
+        token_text = token_path.read_text(encoding="utf-8")
+        token_data = json.loads(token_text)
+        stored = token_data["tokens"][0]
+
+        assert token_data["version"] == "2.0"
+        assert raw_token not in token_text
+        assert "token" not in stored
+        assert stored["token_digest"].startswith("sha256:")
+        assert len(stored["token_digest"]) == len("sha256:") + 64
+        assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
+
+        listed = await manager.list_tokens()
+        assert raw_token not in json.dumps(listed)
+        assert "token_digest" not in listed[0]
+        assert (await manager.validate_token(raw_token)).is_valid is True
+    finally:
+        manager.stop_hot_reload()
+
+    reloaded = TokenManager(config)
+    try:
+        result = await reloaded.validate_token(raw_token)
+        assert result.is_valid is True
+        assert result.token_info.token_id == "generated"
+        assert result.token_info.description == "one-time display"
+    finally:
+        reloaded.stop_hot_reload()
+
+
[email protected]
+async def 
test_legacy_plaintext_file_is_atomically_migrated_without_expiry_extension(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    token_path.write_text(
+        json.dumps(
+            {
+                "version": "1.0",
+                "token": STATIC_TOKEN,
+                "tokens": [
+                    {
+                        "token_id": "legacy",
+                        "token": STATIC_TOKEN,
+                        "legacy_raw_copy": STATIC_TOKEN,
+                        "created_at": "2026-07-01T00:00:00Z",
+                        "expires_hours": 720,
+                        "description": "legacy deployment",
+                        "is_active": True,
+                    }
+                ],
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    config = _config(tmp_path)
+    manager = TokenManager(config)
+    try:
+        migrated_text = token_path.read_text(encoding="utf-8")
+        migrated = json.loads(migrated_text)
+        stored = migrated["tokens"][0]
+
+        assert migrated["version"] == "2.0"
+        assert STATIC_TOKEN not in migrated_text
+        assert "token" not in stored
+        assert stored["token_digest"] == build_token_digest(STATIC_TOKEN, 
"sha256")
+        assert stored["created_at"] == "2026-07-01T00:00:00Z"
+        assert stored["expires_at"] == "2026-07-31T00:00:00Z"
+        assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
+        assert list(tmp_path.glob(".tokens.json.*.tmp")) == []
+    finally:
+        manager.stop_hot_reload()
+
+    sha512_default = _config(tmp_path, algorithm="sha512")
+    reloaded = TokenManager(sha512_default)
+    try:
+        result = await reloaded.validate_token(STATIC_TOKEN)
+        assert result.is_valid is True
+        assert result.token_info.created_at == datetime(2026, 7, 1)
+        assert result.token_info.expires_at == datetime(2026, 7, 31)
+    finally:
+        reloaded.stop_hot_reload()
+
+
+def test_legacy_plaintext_file_fails_closed_when_migration_cannot_be_written(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    token_path.write_text(
+        json.dumps(
+            {
+                "version": "1.0",
+                "tokens": [
+                    {
+                        "token_id": "legacy",
+                        "token": STATIC_TOKEN,
+                        "is_active": True,
+                    }
+                ],
+            }
+        ),
+        encoding="utf-8",
+    )
+    monkeypatch.setattr(
+        TokenManager,
+        "_atomic_write_token_file",
+        lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read only")),
+    )
+
+    with pytest.raises(ValueError, match="digest-only storage"):
+        TokenManager(_config(tmp_path))
+    assert STATIC_TOKEN in token_path.read_text(encoding="utf-8")
+
+
[email protected]
+async def test_digest_file_bootstraps_static_auth_with_embedded_algorithm(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    token_path.write_text(
+        json.dumps(
+            {
+                "version": "2.0",
+                "tokens": [
+                    {
+                        "token_id": "sha512-record",
+                        "token_digest": build_token_digest(STATIC_TOKEN, 
"sha512"),
+                        "created_at": "2026-07-29T00:00:00Z",
+                        "expires_at": None,
+                        "last_used": None,
+                        "is_active": True,
+                    }
+                ],
+            }
+        ),
+        encoding="utf-8",
+    )
+    config = _config(tmp_path, algorithm="sha256")
+    config.security.enable_token_auth = True
+    _mark_source(config, "enable_token_auth", "test")
+
+    normalize_effective_auth_config(config)
+    manager = TokenManager(config)
+    try:
+        assert (await manager.validate_token(STATIC_TOKEN)).is_valid is True
+    finally:
+        manager.stop_hot_reload()
+
+
[email protected](
+    "entry,error",
+    [
+        ({"token_id": "missing"}, "exactly one"),
+        (
+            {
+                "token_id": "both",
+                "token": STATIC_TOKEN,
+                "token_digest": build_token_digest(STATIC_TOKEN, "sha256"),
+            },
+            "exactly one",
+        ),
+        (
+            {"token_id": "invalid", "token_digest": "sha256:not-a-digest"},
+            "64 hexadecimal",
+        ),
+    ],
+)
+def test_static_auth_rejects_invalid_digest_records(
+    tmp_path,
+    monkeypatch,
+    entry,
+    error,
+):
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    token_path.write_text(json.dumps({"tokens": [entry]}), encoding="utf-8")
+    config = _config(tmp_path)
+    config.security.enable_token_auth = True
+    _mark_source(config, "enable_token_auth", "test")
+
+    with pytest.raises(AuthConfigError, match=error):
+        normalize_effective_auth_config(config)
+
+
+def test_static_auth_rejects_unsupported_digest_algorithm(tmp_path):
+    config = _config(tmp_path, algorithm="md5")
+
+    with pytest.raises(AuthConfigError, match="TOKEN_HASH_ALGORITHM"):
+        normalize_effective_auth_config(config)
+
+
[email protected]
+async def test_create_rolls_back_when_digest_file_cannot_be_persisted(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    manager = TokenManager(_config(tmp_path))
+    monkeypatch.setattr(
+        manager,
+        "_atomic_write_token_file",
+        lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full")),
+    )
+    try:
+        with pytest.raises(OSError, match="disk full"):
+            await manager.create_token("not-persisted", 
custom_token=STATIC_TOKEN)
+        assert manager._tokens == {}
+        assert manager._token_ids == {}
+        assert manager._digest_algorithms == set()
+        assert (await manager.validate_token(STATIC_TOKEN)).is_valid is False
+    finally:
+        manager.stop_hot_reload()
+
+
[email protected]
+async def test_revoke_keeps_live_token_when_digest_file_cannot_be_persisted(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    manager = TokenManager(_config(tmp_path))
+    try:
+        raw_token = await manager.create_token("still-live")
+        monkeypatch.setattr(
+            manager,
+            "_remove_token_from_file",
+            lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read 
only")),
+        )
+
+        assert await manager.revoke_token("still-live") is False
+        assert (await manager.validate_token(raw_token)).is_valid is True
+    finally:
+        manager.stop_hot_reload()
+
+
[email protected]
+async def test_revoke_and_export_keep_every_record_digest_only(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    config = _config(tmp_path)
+    manager = TokenManager(config)
+    try:
+        first = await manager.create_token("first")
+        second = await manager.create_token("second")
+        assert await manager.revoke_token("first") is True
+
+        primary_text = 
Path(config.security.token_file_path).read_text(encoding="utf-8")
+        primary = json.loads(primary_text)
+        assert first not in primary_text
+        assert second not in primary_text
+        assert [entry["token_id"] for entry in primary["tokens"]] == ["second"]
+        assert set(primary["tokens"][0]) >= {"token_id", "token_digest"}
+        assert "token" not in primary["tokens"][0]
+
+        export_path = tmp_path / "exported-tokens.json"
+        assert await manager.save_tokens_to_file(str(export_path)) is True
+        exported_text = export_path.read_text(encoding="utf-8")
+        exported = json.loads(exported_text)
+        assert first not in exported_text
+        assert second not in exported_text
+        assert exported["version"] == "2.0"
+        assert "token" not in exported["tokens"][0]
+        assert stat.S_IMODE(export_path.stat().st_mode) == 0o600
+    finally:
+        manager.stop_hot_reload()
diff --git a/tokens.json b/tokens.json
index aae0b8f..1ae66dd 100644
--- a/tokens.json
+++ b/tokens.json
@@ -1,10 +1,11 @@
 {
-  "version": "1.0",
-  "description": "Credential-free Doris MCP Server token configuration 
template",
+  "version": "2.0",
+  "description": "Credential-free Doris MCP Server digest-only token 
configuration template",
   "tokens": [],
   "notes": [
     "No usable credential is shipped with the server.",
-    "Before enabling static token authentication, configure at least one 
active token with 32 or more characters generated by a cryptographically secure 
random generator.",
-    "Do not commit deployment credentials to source control."
+    "Create tokens through the protected management API or store a 
self-describing token_digest generated from a high-entropy bearer token.",
+    "Bearer token plaintext is returned only when a token is created and must 
not be written into this file.",
+    "Do not commit deployment credentials or usable token digests to source 
control."
   ]
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to