Vấn đề thực tế: App kết nối database bằng root
Mình đã thấy pattern này ở hơn chục project — từ startup nhỏ đến các team 20+ người có kinh nghiệm hẳn hoi: file .env của ứng dụng có dòng DB_USER=root, và không ai thấy đó là vấn đề. App chạy ngon, deploy ổn, thế là xong.
Cho đến khi có sự cố.
Mình từng phải xử lý database corruption lúc 3 giờ sáng, mất 2 tiếng restore từ backup — từ đó ngồi review lại toàn bộ cách quản lý user MySQL trong các project. Kết quả: một mớ vấn đề mà trước giờ mình coi nhẹ.
Tại sao dùng root cho ứng dụng là sai?
Root account trong MySQL tương đương sudo trong Linux — nó có quyền làm mọi thứ: DROP DATABASE, DELETE toàn bộ record, thậm chí can thiệp vào cấu hình server.
Khi ứng dụng dùng root:
- Một lỗi SQL injection nhỏ có thể xóa sạch toàn bộ database
- Bug trong code có thể vô tình
DROP TABLEthay vìDELETE - Nếu credentials bị lộ, attacker có toàn quyền kiểm soát MySQL server
- Không thể audit ai đã làm gì — mọi thứ đều là “root”
Nguyên tắc cốt lõi trong security là Principle of Least Privilege: chỉ cấp đúng quyền cần thiết, không thêm. MySQL có hệ thống privilege đủ chi tiết để làm điều này — và thiết lập đúng từ đầu không tốn quá 5 phút.
Các lệnh quản lý user và phân quyền trong MySQL
Tạo user mới
Có ba cách tạo user — chọn cách nào tùy vào app kết nối từ đâu:
-- Tạo user chỉ kết nối từ localhost
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'strong_password_here';
-- Tạo user được kết nối từ bất kỳ IP nào
CREATE USER 'appuser'@'%' IDENTIFIED BY 'strong_password_here';
-- Tạo user chỉ từ một IP cụ thể
CREATE USER 'appuser'@'192.168.1.100' IDENTIFIED BY 'strong_password_here';
Điểm hay vấp: 'user'@'localhost' và 'user'@'%' là hai user khác nhau trong MySQL. Nếu bạn tạo user với @'%' nhưng app kết nối qua localhost, MySQL có thể trả về lỗi access denied mà không có lý do rõ ràng nào.
Cấp quyền với GRANT
MySQL có hơn 30 loại privilege, nhưng app web thông thường chỉ cần quan tâm vài loại sau:
-- Cấp quyền đọc/ghi cho một database (phù hợp app thông thường)
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp_db.* TO 'appuser'@'localhost';
-- Cấp quyền đầy đủ trên một database (cho dev/admin)
GRANT ALL PRIVILEGES ON myapp_db.* TO 'devuser'@'localhost';
-- Cấp quyền chỉ đọc (phù hợp cho reporting/analytics)
GRANT SELECT ON myapp_db.* TO 'readonly_user'@'localhost';
-- Cấp quyền trên một bảng cụ thể
GRANT SELECT, INSERT ON myapp_db.orders TO 'appuser'@'localhost';
-- Áp dụng thay đổi ngay lập tức
FLUSH PRIVILEGES;
Xem quyền hiện tại
-- Xem quyền của một user cụ thể
SHOW GRANTS FOR 'appuser'@'localhost';
-- Xem quyền của user đang đăng nhập
SHOW GRANTS;
-- Xem danh sách tất cả user trong hệ thống
SELECT user, host, account_locked FROM mysql.user;
Thu hồi quyền với REVOKE
-- Thu hồi quyền DELETE (giữ lại SELECT, INSERT, UPDATE)
REVOKE DELETE ON myapp_db.* FROM 'appuser'@'localhost';
-- Thu hồi toàn bộ quyền trên database
REVOKE ALL PRIVILEGES ON myapp_db.* FROM 'appuser'@'localhost';
-- Xóa user hoàn toàn
DROP USER 'appuser'@'localhost';
Đổi mật khẩu user
-- MySQL 5.7.6 trở lên
ALTER USER 'appuser'@'localhost' IDENTIFIED BY 'new_strong_password';
FLUSH PRIVILEGES;
Best practices từ kinh nghiệm thực chiến
1. Mỗi ứng dụng một user riêng
Server chạy 3 app (blog, shop, CRM)? Tạo 3 user riêng biệt với quyền trên database tương ứng. Khi một app bị compromise, attacker không thể nhảy sang database của app khác.
CREATE USER 'blog_user'@'localhost' IDENTIFIED BY 'pass_blog';
GRANT SELECT, INSERT, UPDATE, DELETE ON blog_db.* TO 'blog_user'@'localhost';
CREATE USER 'shop_user'@'localhost' IDENTIFIED BY 'pass_shop';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_db.* TO 'shop_user'@'localhost';
2. Không bao giờ cấp GRANT OPTION cho app user
GRANT OPTION cho phép user đó cấp quyền cho user khác — về bản chất đây là quyền admin. Chỉ DBA mới nên có. Nếu bạn thấy GRANT ... WITH GRANT OPTION trong script setup của team, đó là dấu hiệu cần xem lại toàn bộ security model.
3. Đặt giới hạn resource cho user
Một user bị exploit có thể gửi hàng nghìn query/giây và kéo sập cả server. Đặt giới hạn resource để ngăn điều đó trước khi nó xảy ra:
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'pass'
WITH MAX_QUERIES_PER_HOUR 10000
MAX_CONNECTIONS_PER_HOUR 200
MAX_USER_CONNECTIONS 20;
4. Audit định kỳ — xóa user không dùng nữa
Mỗi tháng, chạy lệnh này và nhìn qua danh sách:
SELECT user, host, password_last_changed, account_locked
FROM mysql.user
ORDER BY password_last_changed;
Dev đã nghỉ việc, project đã đóng, staging cũ không còn ai dùng — DROP hết. Đừng để đống “tài khoản ma” nằm đó chờ bị khai thác.
Setup chuẩn cho project mới
Workflow mình dùng mỗi khi setup database — tách rõ app user và deploy user:
-- Bước 1: Tạo database
CREATE DATABASE myproject_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Bước 2: Tạo user cho app (chạy hàng ngày trong production)
CREATE USER 'myproject_app'@'localhost' IDENTIFIED BY 'strong_app_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON myproject_db.* TO 'myproject_app'@'localhost';
-- Bước 3: Tạo user cho migration/deploy (chỉ dùng khi deploy)
CREATE USER 'myproject_deploy'@'localhost' IDENTIFIED BY 'strong_deploy_password';
GRANT ALL PRIVILEGES ON myproject_db.* TO 'myproject_deploy'@'localhost';
-- Bước 4: Apply và verify
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'myproject_app'@'localhost';
SHOW GRANTS FOR 'myproject_deploy'@'localhost';
App user chỉ có SELECT, INSERT, UPDATE, DELETE — không DROP, không ALTER. Deploy user có ALL PRIVILEGES nhưng credentials này không đưa vào .env production. Nó chỉ sống trong CI/CD pipeline, dùng khi chạy migration, xong là xong.
Sau cái lần phải khôi phục database lúc 3 giờ sáng đó, mình áp dụng nguyên tắc này cho tất cả project và thêm một thói quen nữa: kiểm tra backup hàng ngày. Phân quyền đúng giúp giảm thiệt hại khi sự cố xảy ra — nhưng backup mới là thứ thực sự cứu mình khi mọi thứ sụp đổ.

