Skip to content

Usuarios y permisos ​

Conectar tu aplicación con el usuario root es como darle a cada empleado de una tienda la llave maestra, la combinación de la caja fuerte y la contraseña del banco. Si alguien encuentra una vulnerabilidad en tu sitio (una inyección SQL, un archivo .env expuesto), con root puede borrar todas las bases de datos del servidor. En esta lección aprenderás a crear usuarios con el privilegio mínimo necesario.

Concepto ​

En MySQL, un usuario se identifica por dos partes: nombre y host desde donde se conecta.

txt
'tienda_app'@'localhost'      ← solo puede conectarse desde el mismo servidor
'tienda_app'@'192.168.1.50'   ← solo desde esa IP
'tienda_app'@'%'              ← desde cualquier lugar (¡cuidado!)
'reportes'@'10.0.0.%'         ← desde cualquier IP de la red 10.0.0.x

Para MySQL, 'ana'@'localhost' y 'ana'@'%' son dos usuarios distintos, con contraseñas y permisos independientes.

Los permisos (privilegios) se otorgan en distintos niveles:

NivelSintaxisEjemplo
GlobalON *.*Administradores
Base de datosON tienda.*Usuario de una app
TablaON tienda.productosUsuario que solo lee el catálogo
ColumnaSELECT (nombre, precio) ON tienda.productosMuy restrictivo
RutinaEXECUTE ON PROCEDURE tienda.cancelar_pedidoSolo ejecutar un procedimiento

Crear usuarios ​

sql
-- Usuario local para la aplicación
CREATE USER 'tienda_app'@'localhost' IDENTIFIED BY 'Una-Clave-Larga-y-Unica-2024!';

-- Usuario que se conecta desde la IP del servidor web
CREATE USER 'tienda_app'@'10.0.0.15' IDENTIFIED BY 'Otra-Clave-Distinta!';

-- Evitar error si ya existe
CREATE USER IF NOT EXISTS 'reportes'@'localhost' IDENTIFIED BY 'Clave-Reportes!';

-- Ver usuarios existentes
SELECT user, host, plugin, account_locked FROM mysql.user ORDER BY user;
userhostpluginaccount_locked
mysql.syslocalhostcaching_sha2_passwordY
reporteslocalhostcaching_sha2_passwordN
rootlocalhostauth_socketN
tienda_app10.0.0.15caching_sha2_passwordN
tienda_applocalhostcaching_sha2_passwordN

caching_sha2_password

Es el método de autenticación por defecto en MySQL 8. Algunos drivers muy viejos (PHP antes de 7.4, conectores antiguos) no lo soportan. La solución correcta es actualizar el driver. Como último recurso: CREATE USER ... IDENTIFIED WITH mysql_native_password BY '...' (obsoleto y deshabilitado por defecto en 8.4).

Otorgar permisos: GRANT ​

sql
GRANT privilegios ON nivel TO 'usuario'@'host';

Usuario típico de una aplicación web ​

sql
-- Lo que necesita una app para operar (CRUD), nada más
GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'tienda_app'@'localhost';

Usuario para migraciones (despliegues) ​

sql
-- Puede cambiar la estructura de la BD tienda, pero no tocar otras bases
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX,
      REFERENCES, CREATE VIEW, SHOW VIEW, TRIGGER, EXECUTE,
      CREATE ROUTINE, ALTER ROUTINE
ON tienda.* TO 'tienda_migraciones'@'localhost';

Usuario de solo lectura (reportes, analistas, BI) ​

sql
GRANT SELECT ON tienda.* TO 'reportes'@'localhost';

Solo algunas tablas o columnas ​

sql
-- El equipo de marketing ve clientes pero sin email
GRANT SELECT (id, nombre, ciudad, fecha_registro) ON tienda.clientes TO 'marketing'@'localhost';
GRANT SELECT ON tienda.productos TO 'marketing'@'localhost';

Usuario para respaldos ​

sql
CREATE USER 'backup'@'localhost' IDENTIFIED BY 'Clave-Backup-Segura!';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, PROCESS ON *.* TO 'backup'@'localhost';
-- En MySQL 8.0.21+ también: GRANT RELOAD ON *.* si usas --flush-logs

Privilegios más comunes ​

PrivilegioPermite
SELECT, INSERT, UPDATE, DELETELeer y modificar datos
CREATE, ALTER, DROPCrear, modificar y borrar tablas/bases
INDEXCrear y borrar índices
REFERENCESCrear claves foráneas
CREATE VIEW, SHOW VIEWVistas
TRIGGERTriggers
EXECUTE, CREATE ROUTINE, ALTER ROUTINEProcedimientos y funciones
LOCK TABLESBloquear tablas (respaldos)
PROCESSVer todas las consultas en ejecución
ALL PRIVILEGESTodo en ese nivel (excepto GRANT OPTION)
GRANT OPTIONOtorgar sus permisos a otros

Nunca hagas esto para una app

sql
GRANT ALL PRIVILEGES ON *.* TO 'app'@'%' WITH GRANT OPTION;

Es equivalente a crear otro root accesible desde cualquier lugar del mundo. Un atacante que obtenga esas credenciales controla todo el servidor.

Ver y quitar permisos ​

sql
-- Ver permisos de un usuario
SHOW GRANTS FOR 'tienda_app'@'localhost';
txt
+--------------------------------------------------------------------------------+
| Grants for tienda_app@localhost                                                |
+--------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `tienda_app`@`localhost`                                 |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `tienda`.* TO `tienda_app`@`localhost` |
+--------------------------------------------------------------------------------+

USAGE significa "puede conectarse, sin privilegios globales".

sql
-- Mis propios permisos
SHOW GRANTS;

-- Quitar un permiso
REVOKE DELETE ON tienda.* FROM 'tienda_app'@'localhost';

-- Quitar todo
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'tienda_app'@'localhost';

¿Y FLUSH PRIVILEGES?

Con CREATE USER, GRANT y REVOKE los cambios se aplican de inmediato: no necesitas FLUSH PRIVILEGES. Solo hace falta si alguien modificó directamente las tablas de mysql.* (algo que no deberías hacer).

Administrar cuentas ​

sql
-- Cambiar contraseña
ALTER USER 'tienda_app'@'localhost' IDENTIFIED BY 'Nueva-Clave-2025!';

-- Cambiar tu propia contraseña
ALTER USER USER() IDENTIFIED BY 'Mi-Nueva-Clave!';

-- Bloquear / desbloquear (por ejemplo, un empleado que salió)
ALTER USER 'marketing'@'localhost' ACCOUNT LOCK;
ALTER USER 'marketing'@'localhost' ACCOUNT UNLOCK;

-- Forzar cambio de contraseña cada 90 días
ALTER USER 'reportes'@'localhost' PASSWORD EXPIRE INTERVAL 90 DAY;

-- Bloqueo tras 5 intentos fallidos durante 1 día (MySQL 8.0.19+)
ALTER USER 'tienda_app'@'localhost' FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1;

-- Limitar conexiones simultáneas
ALTER USER 'reportes'@'localhost' WITH MAX_USER_CONNECTIONS 5;

-- Renombrar y eliminar
RENAME USER 'marketing'@'localhost' TO 'mkt'@'localhost';
DROP USER IF EXISTS 'mkt'@'localhost';

Roles (MySQL 8+) ​

Un rol es un conjunto de privilegios con nombre. En lugar de dar permisos uno por uno a cada persona, los das al rol y asignas el rol.

sql
-- 1. Crear roles
CREATE ROLE 'rol_lectura', 'rol_escritura', 'rol_dev';

-- 2. Dar permisos a los roles
GRANT SELECT ON tienda.* TO 'rol_lectura';
GRANT INSERT, UPDATE, DELETE ON tienda.* TO 'rol_escritura';
GRANT ALL PRIVILEGES ON tienda.* TO 'rol_dev';

-- 3. Asignar roles a usuarios
CREATE USER 'laura'@'%' IDENTIFIED BY 'Clave-Laura!' REQUIRE SSL;
GRANT 'rol_lectura', 'rol_escritura' TO 'laura'@'%';

-- 4. Activarlos por defecto al conectarse (¡importante!)
SET DEFAULT ROLE ALL TO 'laura'@'%';

Error común con roles

Asignar el rol pero olvidar SET DEFAULT ROLE. El usuario se conecta y "no tiene permisos" porque los roles quedan inactivos hasta que ejecuta SET ROLE ALL;. Comprueba con SELECT CURRENT_ROLE();.

MariaDB también tiene roles (desde 10.0.5) con sintaxis similar.

Acceso remoto seguro ​

Por defecto MySQL en Ubuntu solo escucha en 127.0.0.1. Para permitir conexiones de otro servidor:

  1. Editar bind-address en /etc/mysql/mysql.conf.d/mysqld.cnf:
ini
[mysqld]
# Solo local (por defecto, lo más seguro):
# bind-address = 127.0.0.1
# IP privada del servidor de BD (red interna):
bind-address = 10.0.0.10
  1. Reiniciar: sudo systemctl restart mysql.
  2. Crear el usuario solo para la IP del servidor de aplicación (no %).
  3. Abrir el firewall solo para esa IP:
bash
sudo ufw allow from 10.0.0.15 to any port 3306 proto tcp
  1. Exigir cifrado:
sql
ALTER USER 'tienda_app'@'10.0.0.15' REQUIRE SSL;

Mejor aún: túnel SSH

Para conectarte tú (desarrollador) a la BD de producción, no abras el 3306. Usa un túnel SSH: ssh -L 3307:127.0.0.1:3306 usuario@servidor y conecta tu cliente a 127.0.0.1:3307. Workbench y DBeaver lo hacen desde su configuración.

Plantilla: usuarios para un proyecto real ​

sql
-- =============================================
--  Usuarios del proyecto "tienda"
-- =============================================
CREATE DATABASE IF NOT EXISTS tienda CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

-- App web (producción): solo CRUD
CREATE USER 'tienda_app'@'localhost' IDENTIFIED BY 'CAMBIAR_app';
GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON tienda.* TO 'tienda_app'@'localhost';

-- Migraciones (se usa solo al desplegar)
CREATE USER 'tienda_migra'@'localhost' IDENTIFIED BY 'CAMBIAR_migra';
GRANT ALL PRIVILEGES ON tienda.* TO 'tienda_migra'@'localhost';

-- Reportes / BI: solo lectura, pocas conexiones
CREATE USER 'tienda_reportes'@'localhost' IDENTIFIED BY 'CAMBIAR_reportes'
  WITH MAX_USER_CONNECTIONS 3;
GRANT SELECT ON tienda.* TO 'tienda_reportes'@'localhost';

-- Respaldos
CREATE USER 'tienda_backup'@'localhost' IDENTIFIED BY 'CAMBIAR_backup';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT ON tienda.* TO 'tienda_backup'@'localhost';
GRANT PROCESS ON *.* TO 'tienda_backup'@'localhost';

Buena práctica

  • Un usuario por aplicación y por función (app, migraciones, reportes, respaldos).
  • Contraseñas largas y únicas, generadas con un gestor; guárdalas en .env, nunca en el código ni en Git.
  • Host lo más específico posible: localhost o una IP, evita %.
  • Revisa periódicamente con SELECT user, host FROM mysql.user; y elimina cuentas que ya no se usan.

Ejercicios ​

  1. Crea un usuario catalogo@localhost que solo pueda leer las tablas productos y categorias de tienda.
  2. Intenta, conectado como catalogo, ejecutar DELETE FROM productos WHERE id = 1;. ¿Qué error aparece?
  3. Crea un rol rol_ventas con SELECT en toda la BD e INSERT/UPDATE en pedidos y detalle_pedido. Asígnalo a un usuario nuevo y actívalo por defecto.
  4. ¿Por qué 'app'@'%' puede no funcionar al conectarte desde el propio servidor con localhost?
Ver solución
sql
-- 1
CREATE USER 'catalogo'@'localhost' IDENTIFIED BY 'Clave-Catalogo-1!';
GRANT SELECT ON tienda.productos  TO 'catalogo'@'localhost';
GRANT SELECT ON tienda.categorias TO 'catalogo'@'localhost';
  1. ERROR 1142 (42000): DELETE command denied to user 'catalogo'@'localhost' for table 'productos'.
sql
-- 3
CREATE ROLE 'rol_ventas';
GRANT SELECT ON tienda.* TO 'rol_ventas';
GRANT INSERT, UPDATE ON tienda.pedidos        TO 'rol_ventas';
GRANT INSERT, UPDATE ON tienda.detalle_pedido TO 'rol_ventas';

CREATE USER 'vendedor1'@'localhost' IDENTIFIED BY 'Clave-Vendedor-1!';
GRANT 'rol_ventas' TO 'vendedor1'@'localhost';
SET DEFAULT ROLE 'rol_ventas' TO 'vendedor1'@'localhost';
  1. Porque si existe un usuario anónimo ''@'localhost' o el cliente conecta por socket, MySQL elige la coincidencia de host más específica (localhost gana sobre %). Solución: elimina usuarios anónimos (mysql_secure_installation) o crea también 'app'@'localhost'.

Resumen ​

  • Un usuario MySQL es 'nombre'@'host'; localhost y % son cuentas distintas.
  • Aplica el privilegio mínimo: la app solo necesita SELECT, INSERT, UPDATE, DELETE sobre su base.
  • GRANT otorga, REVOKE quita, SHOW GRANTS revisa; no hace falta FLUSH PRIVILEGES.
  • Los roles agrupan permisos; recuerda SET DEFAULT ROLE.
  • Para acceso remoto: bind-address, usuario por IP, firewall, SSL o, mejor, túnel SSH.

Academia Web — aprende, copia y construye.