Ir al contenido

Autenticación

Kevin CMS autentica con better-auth sobre tu base de datos D1. En esta versión hay email y contraseña, con la dirección verificada por correo y restablecimiento de contraseña. El panel trae las pantallas de entrada y de recuperar la contraseña; los proveedores sociales llegan después.

Además de la cookie, una petición puede autenticarse con un token Bearer o con una clave de API (para un script, un servidor o una app que no es un navegador). Una cuenta suspendida no autentica por ninguna vía. Ver Bearer y claves de API y Cuentas suspendidas y administración de cuentas.

  1. nodejs_compat en tu wrangler.jsonc. better-auth usa node:crypto y sin esa marca el sitio no arranca:

    wrangler.jsonc
    { "compatibility_flags": ["nodejs_compat"] }
  2. Un secreto con el que firmar las sesiones. En local va en .dev.vars, que está en tu .gitignore:

    Ventana de terminal
    pnpm cms secret:set

    Lo genera con randomBytes(32) y escribe BETTER_AUTH_SECRET en .dev.vars sin pisar nada de lo que ese fichero ya tenga. El valor no se imprime: lo que dice el comando es dónde quedó. Si la clave ya está puesta no la pisa; para rotarla, --force.

  3. En producción, como secreto del Worker:

    Ventana de terminal
    pnpm cms secret:set --remote

    Viaja por la entrada estándar, así que no queda en la tabla de procesos ni en el historial del shell. Rotarlo invalida todas las sesiones abiertas.

  4. Genera y aplica la migración con las tablas de sesión:

    Ventana de terminal
    npx cms db:migrate
    npx cms db:apply

No hay ninguna opción que configurar en cms.config.ts: si el secreto está, la autenticación funciona.

Por defecto, better-auth deduce el origen de cada petición, que es lo que quiere un Worker: funciona sin configurar nada y sigue funcionando en cualquier dominio de vista previa.

Si tu sitio vive detrás de un proxy y el Host que llega no es el público, pon la URL real en el siteUrl de los ajustes y pasará a ser la URL base. De ella salen los enlaces de verificación y de restablecimiento. El correo de restablecimiento lleva a /admin/reset/:token bajo esa misma URL (o bajo el origen de la petición si no hay siteUrl), no a un endpoint de better-auth.

El codegen emite cinco tablas junto a las de tus colecciones, en el mismo .cms/schema.ts y en la misma migración, para que no haya dos sistemas de migraciones conviviendo.

Tabla Qué guarda ¿Es una colección?
users Las cuentas Sí
session Las sesiones abiertas No
account Las credenciales, incluido el hash de la contraseña No
verification Los tokens de un solo uso No
apikey Las claves de API, con la clave guardada con hash No

Las cuatro que no son colecciones son fontanería: no aparecen en locals.cms.collections, ni en el admin, ni en la API REST. Un GET /api/cms/session responde 404, igual que cualquier slug que no existe.

Al actualizar desde una versión sin Bearer, claves ni suspensión de cuentas, cms db:migrate genera una migración solo aditiva: CREATE TABLE apikey (con sus tres índices), ALTER TABLE users ADD para banned, banReason y banExpires, y ALTER TABLE session ADD impersonatedBy. Las tablas users y session no se recrean y ninguna fila existente cambia: las columnas nuevas nacen en NULL. Léela y aplícala como cualquier migración: Schema y migraciones. impersonatedBy la pide el plugin de administración aunque la suplantación esté cerrada; queda siempre en NULL.

La colección users del CMS es la tabla de usuarios de better-auth. Es un solo sitio: se gestionan desde el admin como cualquier otra colección y a la vez son el sujeto de la sesión.

Si no la declaras, se añade sola. Si la declaras, se fusiona: tus campos se conservan y se añaden los que la autenticación necesita.

cms.config.ts
defineCollection({
slug: 'users',
fields: [
{ name: 'bio', type: 'textarea' },
{ name: 'twitter', type: 'text' },
],
})

Esa colección acaba con tus dos campos y con email, name, emailVerified, image, role y los tres de suspensión (banned, banReason, banExpires). El email acaba siempre required y unique aunque no lo declares: sin el índice único, dos cuentas podrían compartir dirección.

role es el rol de la cuenta, un text sin valor por defecto que llega a user.role en la sesión. La primera cuenta recibe el que nombres en admin.firstUserRole; las demás, NULL. Nadie se lo cambia a sí mismo, ni por la API ni por el panel. Cómo se usa en una política, en Control de acceso.

banned, banReason y banExpires los escribe el plugin de administración al suspender una cuenta (ver Cuentas suspendidas) y están protegidos igual que role: un PATCH a /api/cms/users/:id que los mande responde 400, también a un administrador; solo el sistema (SQL, o overrideAccess: true) las escribe. Una cuenta suspendida no autentica por ninguna vía: ni cookie, ni Bearer, ni clave de API.

Declarar uno de esos campos con un tipo incompatible —un email de tipo number— es un error de config que nombra el campo y el tipo que se esperaba.

/api/auth/* lo sirve better-auth entero. Kevin CMS no reimplementa nada.

Método Ruta Qué hace
POST /api/auth/sign-up/email Crea la primera cuenta, ya verificada. Después, 403
POST /api/auth/sign-in/email Inicia sesión, o 403 si falta verificar
POST /api/auth/sign-out Cierra la sesión
GET /api/auth/get-session La sesión actual, o null
GET /api/auth/verify-email El enlace del correo de verificación
POST /api/auth/send-verification-email Reenvía el correo de verificación
POST /api/auth/request-password-reset Manda el correo de restablecimiento
POST /api/auth/reset-password Guarda la contraseña nueva
Ventana de terminal
curl -X POST http://localhost:4321/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"email":"kevin@example.com","password":"una-contraseña-larga","name":"Kevin"}'

La sesión viaja en una cookie, así que desde el navegador no tienes que hacer nada. Con curl, guarda las cookies con -c y reenvíalas con -b.

Una contraseña incorrecta y un email que no existe dan la misma respuesta, un 401. Es deliberado: distinguirlas permitiría averiguar qué direcciones tienen cuenta. Una cuenta sin verificar es distinta — un 403— porque ahí sí hay algo que el usuario puede hacer.

El Bearer, las claves de API y la administración de cuentas son plugins de better-auth, y sus endpoints cuelgan del mismo /api/auth/*. Solo están abiertos los que actúan sobre las claves de quien llama; el resto responde 404, igual que una ruta que no existe, y no hace nada:

Método Ruta Quién Qué hace
POST /api/auth/api-key/create Con sesión Crea una clave para ti. La respuesta es la única vez que trae la clave
GET /api/auth/api-key/list Con sesión Tus claves, sin la clave
POST /api/auth/api-key/delete Con sesión Revoca una clave tuya ({ "keyId": "…" })

Cada una actúa solo sobre las claves de la cuenta que llama: list no devuelve las de otra, delete no borra la de otra (ni siquiera a un administrador) y create rechaza un userId ajeno y los ajustes que solo fija el servidor. Una clave no vale para llamarlas: hace falta una sesión (cookie o Bearer).

Los cierres son decisión, no olvido, y cada uno tiene su motivo:

Ruta cerrada (404) Por qué
/admin/set-user-password, /admin/revoke-user-sessions, /admin/ban-user, /admin/unban-user El plugin decide quién puede llamarlos solo por el nombre del rol (admin.firstUserRole, o admin) y no mira el access de la colección users. Con una política más estrecha en tu config —un update de users que exige un rol superior— una cuenta con ese nombre de rol cambiaría la contraseña de un rol superior: tomar su cuenta. Siguen cerradas: el panel de usuarios hace esas cuatro cosas desde el servidor y con el access del CMS delante (#271), sin pasar por estas rutas ni por el mapa de roles del plugin
/admin/set-role Abriría el camino a users.role que el CMS cierra a propósito: el rol se escribe con admin.firstUserRole o por SQL, no por la API
/admin/impersonate-user, /admin/stop-impersonating Crearía una sesión a nombre de otra cuenta
/admin/list-user-sessions, /admin/revoke-user-session El primero devolvería los tokens de sesión de otros (con Bearer, credenciales); el segundo se apoya en ellos. Usa revoke-user-sessions
/admin/remove-user Se saltaría las guardas de borrado de la colección users
/admin/create-user, /admin/update-user, /admin/list-users, /admin/get-user Esquivarían el access de la colección users. El alta la hace el panel desde el servidor (sin ruta HTTP), y aunque create-user se abriera, el hook de alta lo rechazaría: una petición HTTP lleva request
/admin/has-permission No sirve de nada al CMS
/api-key/get, /api-key/update Una clave no se edita ni se relee: se revoca y se crea otra

disabledPaths actúa solo sobre HTTP: el código del servidor sigue teniendo auth.api.*. Y es fail-closed: cualquier endpoint que un plugin añada en una versión futura de better-auth queda en 404 hasta que alguien lo decide en el código, y una prueba falla hasta entonces.

La primera cuenta de una instalación nace verificada. No podría ser de otra forma: la verificación es obligatoria, y el correo que la desbloquea sale con el remitente que esa misma persona todavía no ha configurado. Así que se da de alta y entra, sin recibir nada.

Lo normal es no hacerlo a mano: la primera visita a /admin en una instalación vacía es el asistente de configuración, que pide los ajustes del sitio y la primera cuenta en un solo formulario y deja la sesión abierta.

Esa primera cuenta recibe el rol de admin.firstUserRole, si lo declaras, y de ese rol depende quién puede, sin más access, editar y borrar cuentas ajenas y cambiar los ajustes del sitio (ver Las cuentas sin access). cms init y create-cms lo escriben en el config que generan: admin: { firstUserRole: 'admin' }.

Todo usuario posterior —una vez el admin sepa invitarlos— sigue el flujo completo. El alta no deja sesión iniciada y no manda correo: el correo sale en el primer intento de iniciar sesión, que además se rechaza:

  1. La cuenta se crea con la dirección sin verificar. La respuesta no trae cookie.

  2. POST /api/auth/sign-in/email responde 403, y en ese momento envía el correo de verificación.

    En local no hace falta buzón: wrangler dev simula el envío y escribe la ruta del fichero con el mensaje en la consola.

  3. El enlace del correo apunta a GET /api/auth/verify-email. Seguirlo marca la dirección.

  4. POST /api/auth/sign-in/email responde ahora 200 con la cookie.

¿Por qué en el inicio de sesión y no en el alta? Porque quien se da de alta y no vuelve nunca genera un correo que nadie iba a abrir, y quien sí vuelve lo recibe justo cuando lo necesita — recién pedido, no caducado. Si hace falta otro, POST /api/auth/send-verification-email lo reenvía.

Desde el panel: en /admin/login, «¿Olvidaste tu contraseña?» lleva a /admin/forgot, que pide el correo.

  1. /admin/forgot contesta siempre lo mismo, exista la cuenta o no: «Si hay una cuenta con ese correo, te hemos mandado un enlace». Solo si existe sale el correo, con un enlace a /admin/reset/:token.

  2. /admin/reset/:token enseña el formulario de la contraseña nueva (con confirmación). Abrir el enlace no lo gasta: el token se consume al guardar la contraseña, así que un antivirus de correo que abra la página no deja el enlace inservible.

  3. Al guardar, el panel te lleva a /admin/login con el aviso «Contraseña cambiada». No inicia sesión: entras con la contraseña nueva.

Un enlace caducado, ya usado o inventado enseña la misma pantalla, «Este enlace no es válido», con un enlace para pedir otro. No dice cuál de los tres era ni nada de la cuenta. El token dura una hora y se usa una sola vez: pedir otro enlace no anula el anterior, pero usar uno borra los demás que esa cuenta tuviera pendientes.

Una contraseña demasiado corta o larga, o dos contraseñas que no coinciden, se señalan en el formulario sin gastar el enlace. Las reglas de longitud son las de better-auth.

Estas dos pantallas son para quien no tiene sesión: con una abierta, /admin/forgot y /admin/reset/:token llevan al escritorio, igual que /admin/login, y el enlace sigue valiendo. Quien ya está dentro cambia la contraseña en /admin/account. En una instalación sin ninguna cuenta, las dos llevan al asistente.

Completar el reset cierra todas las sesiones abiertas de esa cuenta, no solo las de otros navegadores: quien restablece la contraseña ya ha perdido el control de la suya, así que la sesión de quien se la quitó se cae con las demás. También borra todas las claves de API de la cuenta, y las integraciones que las usaban dejan de funcionar hasta que se generen otras (ver qué le pasa a una clave cuando cambia la contraseña): la pantalla de la contraseña nueva lo avisa antes de guardar.

El límite de intentos es el de better-auth, el mismo que el de la API y no uno aparte: 3 peticiones de enlace por hora y por IP y, para guardar la contraseña, el general de /api/auth/* (60 por minuto). Pasado el tope, el formulario lo dice sobre la misma pantalla, sin romperla. Ver Límites.

Por la API sigue siendo POST /api/auth/request-password-reset y, para guardar la contraseña, un POST a /api/auth/reset-password con el token del enlace. El correo de la API ya no depende de redirectTo: siempre lleva a /admin/reset/:token. La respuesta del reset no trae cookie, de modo que hay que volver a /admin/login y entrar con la contraseña nueva.

interface AuthLocals {
getSession(): Promise<SessionData> // la sesión, o null
getUser(): Promise<SessionUser | null> // el usuario, o null
requireUser(): Promise<SessionUser> // el usuario, o lanza UnauthorizedError (401)
}

Proteger una página son dos líneas:

src/pages/cuenta.astro
---
export const prerender = false
const user = await Astro.locals.cms.auth.getUser()
if (!user) return Astro.redirect('/login')
---
<p>Hola, {user.name}</p>

getSession() se consulta una vez por petición: llamarlo en el layout y otra vez en un componente no cuesta el doble, porque lo que se guarda es la promesa y no el valor —dos llamadas simultáneas también comparten la consulta—. La caché muere con la petición, así que ninguna sesión se filtra a la siguiente.

Nada de esto se construye hasta que lo pides: una página que no lee cms no toca la base de datos.

requireUser() es para tus rutas propias, donde un redirect no sirve. Lanza un UnauthorizedError, y solo la API REST del CMS lo traduce a un 401: en una ruta tuya de Astro, si nada lo atrapa, la respuesta es un 500. Envuélvelo en un try/catch con errorResponse, que se importa de @kevolution-co/cms, y sale el mismo 401 en JSON que da la REST. En una página .astro no lo uses sin más: ahí es getUser() y Astro.redirect, como arriba.

Dos formas de autenticar sin cookie, para un script, un servidor o una app móvil. En las dos, quien actúa es una cuenta: el rol sale del usuario dueño de la sesión o de la clave, se lee en cada petición y se evalúa con las mismas funciones de access que con la cookie. Ni el token ni la clave llevan permisos propios.

El mismo token de la sesión, por cabecera. Lo trae la respuesta de cualquier inicio de sesión, en la cabecera set-auth-token:

Ventana de terminal
curl -i -X POST https://tu-sitio.com/api/auth/sign-in/email \
-H 'content-type: application/json' \
-d '{"email":"kevin@example.com","password":"una-contraseña-larga"}'
# …
# set-auth-token: 4Yx9…firma…
curl https://tu-sitio.com/api/cms/posts -H 'Authorization: Bearer 4Yx9…firma…'
  • El token es el valor firmado, el mismo de la cookie better-auth.session_token. El token crudo que guarda la tabla session (y que devuelve el cuerpo del inicio de sesión) no vale: así, quien lea la tabla —una copia de seguridad, una inyección de solo lectura— no obtiene sesiones usables sin conocer BETTER_AUTH_SECRET.
  • Vale lo que la sesión: caduca cuando ella caduca y deja de valer al cerrarla (/api/auth/sign-out con el mismo Bearer) o al revocarla un administrador.
  • Funciona en la API REST, en /api/auth/* y en Astro.locals.cms.auth de tus páginas.

Para un cliente que no inicia sesión, una clave de larga vida. Lo normal es crearla desde /admin/account; por HTTP se crea con una sesión (cookie o Bearer):

Ventana de terminal
curl -X POST https://tu-sitio.com/api/auth/api-key/create \
-H 'Authorization: Bearer 4Yx9…firma…' -H 'content-type: application/json' \
-d '{"name":"integración con el CRM","expiresIn":7776000}'

La respuesta trae el campo key. Es la única vez que la verás: en la base solo queda su hash SHA-256 y GET /api/auth/api-key/list no la devuelve. Se usa con la cabecera X-API-Key en la API REST:

Ventana de terminal
curl https://tu-sitio.com/api/cms/posts -H 'X-API-Key: la-clave'
  • La clave es del usuario que la creó y se comporta como esa cuenta: si le cambian el rol, la misma clave cambia con él. Si la cuenta se borra, sus claves se borran con ella; si se suspende, la clave deja de autenticar.
  • Solo vale para la API REST. No es una sesión de better-auth: no entra en /admin, ni crea más claves ni llama a ningún otro endpoint de /api/auth/*. Tampoco la lee Astro.locals.cms.auth.
  • Toda clave caduca. Sin expiresIn, la clave dura 90 días; el mínimo es un día y el máximo 365 (expiresIn va en segundos). No hay claves eternas: una credencial de larga vida que nadie recuerda es la que acaba filtrada. Las claves creadas antes de esta regla no cambian.
  • Una clave no sobrevive a una contraseña que se restablece: ver más abajo.
  • Regenerar es crear la nueva y revocar la vieja (POST /api/auth/api-key/delete con { "keyId": "…" }); no hay un endpoint que lo haga de una vez (el panel sí: ver más abajo). Revocada, regenerada, caducada o desactivada, la clave recibe un 401.
  • Se guarda start, los 6 primeros caracteres de la clave, para que puedas reconocerla en la lista. Es decisión de better-auth y es parte de lo que ve quien lea la tabla.
  • No hay una clave por usuario forzada en el servidor: un usuario puede crear varias, hasta 10 por hora y por IP por HTTP, y 10 por hora y por usuario desde el panel.
  • El límite por clave de better-auth está apagado (el de serie son 10 peticiones al día, escritas en la fila de cada clave): una API con ese techo no sirve. Una clave que se filtra se corta revocándola; para un tope por petición, pon delante una regla de rate limiting de Cloudflare.
  • Las consultas de better-auth sobreviven al fin de su petición. El plugin lanza, sin esperarla, la limpieza de claves caducadas al final de list y de delete. Si la respuesta salía antes de que acabara, workerd cortaba la consulta y la conexión de better-auth con D1 se quedaba bloqueada: toda petición con sesión que llegaba después esperaba para siempre (y por eso POST /api/auth/api-key/create parecía colgarse en astro dev, #301). El paquete entrega cada consulta de better-auth a waitUntil, así que acaban aunque nadie las espere. Quien monte createAuth a mano tiene que pasarle waitUntil (el último argumento): sin él no se entrega nada.
  • Cada petición con clave cuesta algo más que una con cookie: una lectura de la clave por su hash, una escritura sobre su fila (better-auth actualiza updatedAt en cada verificación) y la lectura del usuario dueño.

Qué le pasa a una clave cuando cambia la contraseña

Sección titulada «Qué le pasa a una clave cuando cambia la contraseña»

Quien roba una clave de API conserva el acceso aunque la persona «recupere» la cuenta, porque la clave dura hasta 365 días y no depende de la contraseña. Por eso el CMS la corta cuando la contraseña cambia por una vía que cierra sesiones:

Qué pasa Sus claves de API
Restablecer la contraseña (/admin/reset/:token, POST /api/auth/reset-password) Se borran todas, siempre
Cambiar la contraseña con revokeOtherSessions: true (POST /api/auth/change-password, auth.api.changePassword, y /admin/account, que lo manda siempre) Se borran todas
Fijar la contraseña de otra cuenta desde el panel de cuentas (/admin/collections/users) Se borran todas las de esa cuenta
Cambiar la contraseña sin esa opción, o con revokeOtherSessions: false Siguen vivas
Un intento que falla (token inválido, contraseña actual incorrecta) No se toca nada
Cualquiera de los de arriba, sobre otra cuenta Las claves de las demás cuentas no se tocan

Las claves se borran y no se desactivan: la fila desaparece y la clave recibe un 401, igual que una revocada a mano. Las integraciones que las usaban dejan de funcionar hasta que generes otras desde /admin/account y las cambies en cada cliente: avísalas antes de restablecer la contraseña de una cuenta con integraciones en producción. Las pantallas de recuperación y de cambio de contraseña lo dicen en el momento.

Fijar la contraseña de otra cuenta desde el panel de cuentas es el flujo de «recuperar una cuenta comprometida» desde el otro lado, y hace lo mismo: cierra sus sesiones, borra sus enlaces de recuperación y borra sus claves de API. Las integraciones de esa cuenta dejan de funcionar hasta que su dueña genere otras desde /admin/account, y el formulario y el aviso posterior lo dicen. Las claves de la cuenta que fija la contraseña, y las de las demás, no se tocan. Una contraseña rechazada (demasiado corta, dos que no coinciden) no borra nada.

/admin/account lista tus claves —nombre, cómo empiezan (los 6 primeros caracteres), último uso, caducidad y estado— y deja generar, regenerar y revocar sin JavaScript: son formularios nativos.

  • La clave en claro se ve una sola vez, en la respuesta del propio envío del formulario, con un aviso encima. No viaja en la URL, ni en una redirección, ni en una cookie, ni se registra; al salir de esa pantalla solo queda el prefijo. Si recargas esa respuesta, el navegador vuelve a enviar el formulario y se acuña otra clave (cuenta para el tope de abajo). No hay botón de copiar: necesitaría JavaScript, y el campo es de solo lectura para copiarlo con el teclado o el ratón.
  • Regenerar y revocar piden confirmación en una pantalla aparte (?confirm=revoke&key=…), que no cambia nada por sí sola: la escritura es un POST. Revocar corta la clave en el acto. Regenerar conserva el nombre y la duración que tenía y borra primero la vieja: si crear la nueva falla, la filtrada ya no vale y el panel lo dice.
  • Al generar se elige el nombre (hasta 32 caracteres) y el plazo de una lista: 30, 90 (el defecto), 180 o 365 días. No se puede elegir otro, ni fijar permisos propios: la clave actúa como tu cuenta, con el rol que tengas en cada petición.
  • Solo con la sesión de cookie del panel. Un Authorization: Bearer o una X-API-Key en la petición reciben un 403 y la pantalla ni lista las claves: una credencial robada no sirve para acuñar otra. Una cuenta suspendida no entra al panel, así que tampoco ve ni genera claves.
  • Solo las tuyas. Un id de una clave ajena y un id que no existe dan el mismo 404, y la clave ajena sigue valiendo. Ni siquiera un administrador gestiona las de otra cuenta desde aquí.
  • Generar y regenerar comparten un tope de 10 por hora y usuario; revocar tiene el suyo (límites).
  • Cambiar tu contraseña desde esta pantalla revoca todas tus claves de API, además de cerrar tus demás sesiones: el panel pide cerrarlas siempre, y las claves van detrás. Las integraciones que las usen dejarán de funcionar hasta que generes otras (el cambio lo avisa). Ver qué le pasa a una clave cuando cambia la contraseña.
La petición lleva Resultado
Nada Anónima, como siempre: lo que access deje a un visitante
Solo la cookie La sesión de la cookie
Authorization: Bearer … válido Su sesión. Manda sobre la cookie: si hay las dos, la cookie no se mira
Authorization: Bearer … inválido, caducado, de una sesión cerrada o de una cuenta suspendida 401, no la cookie de reserva ni una lectura anónima
X-API-Key válida La cuenta dueña. Manda sobre la cookie
X-API-Key revocada, regenerada, caducada, desactivada, inventada o de una cuenta suspendida 401, con el mismo mensaje en todos los casos
Authorization: Bearer y X-API-Key a la vez 401: no se adivina cuál quería
X-API-Key vacía Como si no estuviera
Authorization con otro esquema (Basic …) No es una credencial: se ignora

Un 401 y no «anónima» para una credencial que falla es deliberado: un cliente con la clave vieja tiene que enterarse, no recibir en silencio lo público. Vale también para una lectura de una colección access: { read: 'public' }: con una credencial en la cabecera, el CMS la comprueba aunque la lectura sea pública.

Dentro de /api/auth/* mandan las reglas propias de better-auth, no esta tabla.

Cuentas suspendidas y administración de cuentas

Sección titulada «Cuentas suspendidas y administración de cuentas»

El plugin de administración de better-auth está montado, pero ninguno de sus endpoints tiene ruta HTTP: cambiar la contraseña de otra cuenta, cerrarle las sesiones, suspenderla o levantarle la suspensión por /api/auth/admin/* responde 404 (ver los endpoints cerrados). Lo que hace el panel es lo mismo desde el servidor y con el access de tu config decidiendo antes.

En /admin/collections/users (el botón «Crear» y la pantalla de cada cuenta) quien tiene permiso puede:

Acción Qué pasa
Crear una cuenta Correo, nombre, contraseña inicial y —si el access del campo role lo permite— rol. La cuenta nace con el correo verificado y puede entrar con esa contraseña. No abre el registro: sign-up/email sigue respondiendo 403
Fijar la contraseña de otra cuenta La vieja deja de abrir sesión, se cierran todas sus sesiones, se borran sus enlaces de recuperación pendientes y se borran todas sus claves de API
Cerrar todas las sesiones de otra cuenta Sin tocar nada más de la cuenta
Suspender y levantar la suspensión Con motivo y, si quieres, días (de 1 a 3650). Al suspender se cierran sus sesiones. Solo si el access.update de banned, banReason y banExpires lo permite
Cambiar el rol Solo si tu access.update del campo role lo permite (ver Roles)

Cada acción es un formulario nativo (POST, respuesta 303) y el servidor decide al recibirlo: quitar el botón de la pantalla no es la defensa, un POST a mano se rechaza igual. El orden de las comprobaciones, antes de tocar nada, es este:

  1. Hay una sesión de cookie. Un token Bearer o una X-API-Key en la petición no valen para esto, aunque pertenezcan a quien sí tiene permiso: para tomar cuentas ajenas solo se acepta el navegador.
  2. La cuenta tiene acceso al panel (access.admin de users).
  3. Tu config ha declarado access.create (el alta) o access.update (lo demás) en users: si no, 403 que dice qué declarar. Y la cuenta no ha superado 30 acciones por minuto.
  4. access de users lo permite, con la cuenta de destino delante: tiene que poder leerla y editarla. Una condición de tu update se evalúa contra esa fila. La cuenta de destino sale de la URL, nunca de un campo del formulario.
  5. El destino no es la propia cuenta: nadie se administra a sí mismo desde aquí, ni su rol, ni su suspensión, ni su contraseña. Eso evita quedarse sin acceso al panel por error (quien actúa es siempre una cuenta con acceso, así que quitárselo a otra nunca deja el panel sin nadie). Tu contraseña y tus sesiones se gestionan en Cuenta, donde se pide la actual.

Toda negativa que no sea la del paso 5 es la misma respuesta: 403, el mismo mensaje y ningún dato, tanto si la cuenta no existe, como si no puedes verla, como si no puedes editarla. No sirve para averiguar qué cuentas hay.

La suspensión se decide como el rol, campo a campo: el CMS niega banned, banReason y banExpires a todo el mundo por defecto (ni el PATCH ni el editor los escriben), y el panel no abre lo que esa regla cierra. Para poder suspender y levantar la suspensión desde el panel declara el access.update de los tres campos (se escriben los tres, también al levantarla); la función recibe data con lo que se va a escribir, así que puedes permitir suspender y no levantar. Sin ello, la pantalla enseña el estado de la suspensión y dice qué declarar. Ver el ejemplo en Administrar cuentas desde el panel.

El rol se asigna con el access.update del campo role, que por defecto lo niega a todo el mundo: asignar roles desde el panel es una decisión tuya, y la función recibe data.role para que decidas qué roles puede dar cada rol. Un rol no puede llevar comas (el plugin las lee como varios roles), pasar de 64 caracteres ni llevar caracteres de control.

Estas acciones escriben por internalAdapter de better-auth y no por la Local API de la colección, así que los hooks de la colección users de tu proyecto no se ejecutan al crear una cuenta, cambiar su rol ni suspenderla (un afterChange de auditoría, por ejemplo, no se entera). Si necesitas dejar rastro, hazlo en tu propio código alrededor del panel o en la base de datos.

Tres límites conocidos: dos administradores que se degradan a la vez entre sí pueden dejarse sin acceso (no hay transacción entre llamadas de better-auth), entre escribir la contraseña nueva y cerrar las sesiones hay una ventana de milisegundos, y una cuenta con el rol de administración puede borrarse a sí misma mientras queden otras: la guarda solo impide borrar la última. Si era la única con ese rol, las que quedan no tienen quien administre, y como role es de solo lectura (ni el panel ni la REST lo escriben) no hay forma de volver a dárselo sin SQL. Ver Recuperar el acceso a los ajustes. Borrar una cuenta sigue siendo el delete de la colección, con su propio access.delete, con una excepción que ninguna política levanta, tampoco overrideAccess: true: la última cuenta no se borra (LAST_ACCOUNT, un 400 por REST). Sin ninguna, el registro público volvería a abrirse para quien llegue primero. Vale para la API REST, la Local API y el panel, y también en el borrado en bloque.

Se escribe desde el panel, desde el servidor (auth.api.banUser) o por SQL (banned = 1), y es siempre el mismo:

  • Una cuenta suspendida recibe un 403 con BANNED_USER al iniciar sesión (el panel lo dice con «Esta cuenta está suspendida») y deja de autenticar por cookie, Bearer y clave de API, aunque conserve una sesión o una clave anteriores. banExpires (o banExpiresIn al banear) la levanta sola al cumplirse.
  • Suspender borra las sesiones de la cuenta, y nadie se suspende a sí mismo.

Con la autenticación en su sitio, las reglas de la API REST por fin se pueden cumplir:

Operación Regla
GET Pública si la colección —o el global— declara access: { read: 'public' }; si no, pide sesión
POST, PATCH, DELETE Piden sesión siempre, el PATCH de un global incluido

Esa es la regla sin access. Con una función declarada, la decide ella: 401 sin sesión, 403 si la función dice que no a quien la tiene y 404 si una condición deja fuera el documento. Ver Control de acceso.

«Sesión» es la de la cookie, la de un token Bearer o la cuenta de una clave de API: da igual cuál, la regla es la misma. Una credencial de cabecera que no resuelve a una cuenta es un 401, no una petición anónima.

Estado
Pantalla para reenviar el correo de verificación No. El endpoint existe, la pantalla llega con el admin
Pantallas de olvidé mi contraseña y de contraseña nueva Sí, en /admin/forgot y /admin/reset/:token
Invitar usuarios por correo No. El alta es manual: quien administra escribe el correo, el nombre y una contraseña inicial, y se la da por otro canal
Crear cuentas, fijar su contraseña, suspenderlas, cerrarles las sesiones y darles rol desde el panel Sí, en /admin/collections/users, con el access de users decidiendo. Ver Desde el panel
Google y GitHub No. Se configurarán desde el admin, sin desplegar
Pantalla de login Sí, en /admin/login
Cambiar tu contraseña sabiendo la actual Sí, en /admin/account. Cierra tus demás sesiones
Ver y cerrar tus sesiones abiertas Sí, en /admin/account
Cierre del registro tras el primer usuario Sí. La primera cuenta lo cierra
Control de acceso por rol, documento y campo Sí, con access en el config. Un solo role por cuenta; no hay lista de roles
Token Bearer y claves de API para clientes sin navegador Sí. Las claves valen solo para la API REST
Pantalla para crear, regenerar y revocar claves de API Sí, en /admin/account, solo con la sesión de cookie. Ver Desde la pantalla de cuenta. Sin botón de copiar (necesita JavaScript)
Claves de API sin caducidad No. Toda clave caduca: 90 días si no se dice, 365 como máximo
Suspender, cambiar la contraseña o cerrar sesiones de otra cuenta por HTTP No, a propósito: el plugin decide por el nombre del rol y no mira tu access. Se hace desde el panel, que sí lo mira
Suplantar a otra cuenta (impersonate), cambiar el rol por la API, listar usuarios por /api/auth/admin No, a propósito: ver los endpoints cerrados