Primeros pasos con la autenticación gestionada de Relyt One

Una guía paso a paso para crear una aplicación Next.js multiusuario en Relyt One. Activa la autenticación gestionada en una rama, haz que los usuarios inicien sesión, verifica sus JWT con @relyt/auth y deja que la seguridad a nivel de fila de Postgres mantenga separadas las filas de cada usuario.

Zhan LuActualizado el 30 de octubre de 202615 min de lectura
En esta página

En esta guía construimos una pequeña API de notas multiusuario sobre Relyt One. Los usuarios se registran e inician sesión en la URL de autenticación gestionada de la rama, la aplicación verifica sus tokens con @relyt/auth y la seguridad a nivel de fila de Postgres garantiza que cada usuario solo lea y escriba sus propias notas. En el camino cubrimos las partes que suelen fallar: orígenes de confianza, emisores que no coinciden, roles con BYPASSRLS y conexiones con pooling.

Necesitas un proyecto de Relyt One, Node.js 20 o posterior, psql y unos treinta minutos. Todo lo que hay aquí también funciona con el ejemplo TODO de código abierto, que sigue el mismo patrón con más funciones (inicio de sesión social, equipos y roles, y un conjunto de pruebas). Si quieres el contexto de por qué la autenticación gestionada funciona así, lee primero Autenticación gestionada para Relyt One, ya en vista previa.

Qué vamos a construir

El navegador inicia sesión en la URL de autenticación y obtiene un JWT, la API lo verifica con JWKS y define app.user_id, y RLS filtra la tabla notes

  • Una tabla notes con un propietario por fila, protegida con seguridad a nivel de fila.
  • Una ruta de Next.js /api/notes que lista y crea las notas de quien llama. Solo acepta Authorization: Bearer <jwt>.
  • Un helper para el navegador que inicia la sesión de un usuario en la URL de autenticación y llama a la API con un JWT vigente.

Dos reglas sostienen todo el diseño, y cada paso de abajo se desprende de ellas:

  1. El id del usuario sale solo de un JWT verificado. Nunca de un encabezado, de la query string ni del cuerpo de la solicitud.
  2. La base de datos también lo comprueba. La API filtra por usuario y RLS vuelve a filtrar, así que un WHERE olvidado no puede filtrar las filas de nadie más.

Paso 1: Activa la autenticación gestionada en una rama

En la consola, abre tu proyecto y elige la rama que usará tu aplicación. Mientras desarrollas, una rama secundaria de main es una buena opción: su configuración de autenticación, sus sesiones y sus claves de firma son propias, así que los experimentos nunca tocan los inicios de sesión de producción. (Si creas la rama secundaria después de activar la autenticación en main, empieza con una copia de los orígenes de confianza y de los usuarios finales de main, así que trátala como si tuviera datos reales de usuarios).

Ve a la pestaña Auth (autenticación) y elige Enable auth (activar la autenticación). La primera configuración despierta el cómputo de la rama y crea las tablas de autenticación; tarda alrededor de un minuto, así que deja la página abierta. Solo los propietarios y administradores de la organización pueden hacerlo.

Cuando el estado cambie a Ready (listo), el resumen muestra dos URL:

  • Auth URL (URL de autenticación): donde tus usuarios inician sesión, y el valor que tu backend espera como emisor del token.
  • JWKS URL (URL de JWKS): las claves públicas que tu backend usa para comprobar las firmas de los tokens.

Copia las dos. Los usuarios, las sesiones y las organizaciones ahora se guardan en el esquema relyt_auth de la base de datos de esta rama.

Paso 2: Activa un método de inicio de sesión

En Sign-in methods (métodos de inicio de sesión), asegúrate de que Email + password (correo electrónico y contraseña) esté activado (lo está de forma predeterminada) y guarda. Es todo lo que necesita esta guía.

GitHub y Google funcionan igual una vez que tienes una OAuth App: copia la URL de callback que muestra la consola (apunta a la URL de autenticación, por ejemplo <URL de autenticación>/api/auth/callback/github) en la configuración del proveedor, luego pega el client ID y el secret en la consola y elige Save & enable (guardar y activar). El secret es de solo escritura. Una OAuth App de GitHub solo puede registrar una URL de callback, así que usa una OAuth App por rama.

Paso 3: Confía en el origen de tu aplicación

La URL de autenticación es un origen distinto al de tu aplicación, así que el navegador envía una solicitud CORS cada vez que tu aplicación inicia la sesión de alguien. El servicio de autenticación solo responde a los orígenes que enumeres.

En Redirects & origins (redirecciones y orígenes), agrega el origen exacto de tu aplicación a Trusted origins (orígenes de confianza):

  • http://localhost:3000 mientras desarrollas;
  • https://app.example.com (o donde despliegues) más adelante.

Sin rutas, sin barra final y sin comodines. Si más adelante agregas el inicio de sesión social, registra también en Callback URLs (URL de callback) la página que termina el inicio de sesión, como http://localhost:3000/auth/callback. Las URL de callback se comparan de forma exacta, y el origen de cada una también debe ser un origen de confianza.

Paso 4: Crea la tabla, la política y un rol de aplicación

Conéctate a la rama como propietario de la base de datos (relytdb_owner en la base de datos relytdb, con la contraseña que definiste al crear el proyecto) y crea la tabla:

CREATE TABLE notes (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    text NOT NULL,
  body       text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_user_id_idx ON notes (user_id, created_at DESC);

user_id guarda el claim sub del JWT del usuario, que es un id de texto.

Ahora activa la seguridad a nivel de fila y agrega una política que compare el propietario de cada fila con un valor que la aplicación definirá en cada transacción:

ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY notes_owner ON notes FOR ALL
  USING (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));

Qué hace cada parte:

  • ENABLE activa RLS; FORCE hace que también se aplique al propietario de la tabla, salvo que el propietario tenga BYPASSRLS. En Relyt One, relytdb_owner lo tiene, así que sigue viendo todas las filas (el punto siguiente).
  • USING filtra qué filas puede ver, actualizar o eliminar una consulta.
  • WITH CHECK rechaza las inserciones y actualizaciones que crearían una fila de otra persona.
  • current_setting('app.user_id', true) devuelve el valor, o nada si nunca se definió (el true significa «no lances un error»). Cuando falta, ninguna fila coincide, así que el código que olvida definirlo ve una tabla vacía en lugar de las notas de todos.

Conéctate con un rol al que se le aplique RLS

Este es el paso que la gente se salta, y desactiva en silencio todo lo anterior.

Los superusuarios y los roles con BYPASSRLS ignoran todas las políticas. Los usuarios de base de datos creados desde la consola de Relyt One tienen BYPASSRLS, y también relytdb_owner, el rol propietario con el que empieza todo proyecto. Usa el propietario para crear tablas, nunca para atender solicitudes. Si tu aplicación se conecta con uno de ellos, la política nunca se ejecuta, y lo único que mantiene separados a los usuarios es el código de tu aplicación.

En su lugar, crea con SQL un rol dedicado para la aplicación:

CREATE ROLE notes_app LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password notes_app
GRANT USAGE ON SCHEMA public TO notes_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO notes_app;

\password es un comando de psql que pide la contraseña de forma interactiva, así que nunca termina en el historial de tu shell ni en los logs del servidor. Comprueba el resultado:

SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
--  rolname   | rolbypassrls | rolsuper
-- -----------+--------------+----------
--  notes_app | f            | f

Ambas columnas deben ser f. Un rol creado así inicia sesión tanto con la cadena de conexión directa como con la de pooling; comprobamos todo el patrón de este paso en una rama real, incluido que la política se mantiene detrás del pooler.

Paso 5: Prepara la aplicación

Crea una aplicación Next.js 15 e instala el SDK y el driver de Postgres (@relyt/auth/next es compatible con Next.js 14.2.35+ y 15.5.28+, todavía no con la 16, así que fija la versión):

npx create-next-app@15 notes --ts --app
cd notes
npm install @relyt/auth pg
npm install -D @types/pg

Agrega la configuración a .env.local:

# La Auth URL del paso 1: solo el origen, sin barra final ni ruta.
RELYT_AUTH_ISSUER=https://<tu URL de autenticación>
RELYT_AUTH_JWKS_URL=https://<tu URL de JWKS>

# La misma URL de autenticación, para el navegador.
NEXT_PUBLIC_RELYT_AUTH_URL=https://<tu URL de autenticación>

# Conéctate como notes_app, el rol del paso 4, nunca con un usuario creado desde la consola.
DATABASE_URL=postgresql://notes_app:<contraseña>@<host>/relytdb?sslmode=require

El emisor debe ser exactamente el origen de la URL de autenticación, porque los tokens llevan ese valor tanto en iss como en aud y el SDK lo compara carácter por carácter. sslmode=require también importa: las conexiones a Relyt One usan TLS, y node-postgres no lo activa a menos que se lo pidas.

Paso 6: Una sola entrada a la base de datos

Pon todo el acceso a la base de datos detrás de una única función que abra una transacción, defina app.user_id a partir del id de usuario verificado y solo entonces ejecute tus consultas.

// lib/db.ts
import { Pool, type PoolClient } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

export async function withUser<T>(userId: string, fn: (db: PoolClient) => Promise<T>): Promise<T> {
  if (!userId) throw new Error("withUser: empty user id");
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // `true` = local a esta transacción: una conexión del pool nunca lo arrastra a la siguiente solicitud.
    await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
    const result = await fn(client);
    await client.query("COMMIT");
    client.release();
    return result;
  } catch (e) {
    await client.query("ROLLBACK").catch(() => undefined);
    client.release(true); // descarta una conexión que podría estar rota en lugar de devolverla al pool
    throw e;
  }
}

El tercer argumento de set_config importa más de lo que parece. Sin él, el valor dura toda la sesión y, con un pool de conexiones, la siguiente solicitud que tome prestada esta conexión se ejecutaría como el usuario anterior. Con true, desaparece en el COMMIT o el ROLLBACK.

Lo mismo vale detrás del pooling por transacción de PgBouncer, porque el valor nunca sobrevive a la transacción.

Paso 7: Verifica el token en cada solicitud

getClaims de @relyt/auth/next lee el bearer token, comprueba su firma EdDSA contra el JWKS, comprueba el emisor, la audiencia y la expiración, y devuelve los claims, o null si el token falta o no es válido.

// lib/auth.ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export type Caller = { ok: true; userId: string } | { ok: false; response: Response };

export async function requireUser(req: Request): Promise<Caller> {
  try {
    const claims = await getClaims(
      req,
      { issuer: process.env.RELYT_AUTH_ISSUER!, jwksUrl: process.env.RELYT_AUTH_JWKS_URL },
      { cookie: false }, // las rutas de la API solo aceptan bearer tokens
    );
    if (!claims?.sub) {
      return { ok: false, response: Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } }) };
    }
    return { ok: true, userId: claims.sub };
  } catch (e) {
    if (e instanceof RelytAuthUnavailableError) {
      // No se pudieron obtener las claves. No respondas 401: cerraría la sesión de todos durante un corte de red.
      return { ok: false, response: Response.json({ error: "auth_unavailable" }, { status: 503, headers: { "retry-after": "2" } }) };
    }
    throw e;
  }
}

Aceptar solo bearer tokens (cookie: false) mantiene la API libre de CSRF: un navegador nunca agrega por su cuenta un encabezado Authorization a una solicitud entre sitios.

Paso 8: La API de notas

// app/api/notes/route.ts
import { requireUser } from "@/lib/auth";
import { withUser } from "@/lib/db";

export async function GET(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const notes = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "SELECT id, body, created_at FROM notes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100",
      [caller.userId],
    );
    return rows;
  });
  return Response.json(notes);
}

export async function POST(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const input = await req.json().catch(() => null);
  const body = typeof input?.body === "string" ? input.body.trim() : "";
  if (body.length < 1 || body.length > 2000) return Response.json({ error: "invalid_body" }, { status: 400 });

  const note = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "INSERT INTO notes (user_id, body) VALUES ($1, $2) RETURNING id, body, created_at",
      [caller.userId, body],
    );
    return rows[0];
  });
  return Response.json(note, { status: 201 });
}

Fíjate en que la consulta sigue diciendo WHERE user_id = $1 aunque RLS filtraría de todos modos. Mantener ambos es deliberado: la consulta es rápida y explícita, y la política es la red de seguridad para el día en que alguien escriba una que no lo sea.

Fíjate también en lo que el handler no hace: nunca lee un id de usuario del cuerpo ni de la query string. El user_id que escribe es el del token.

Paso 9: Inicia sesión desde el navegador

El navegador habla con dos lugares: con la URL de autenticación, para iniciar sesión, y con tu API, con un JWT.

// lib/auth-client.ts
"use client";
import { createAuthClient } from "@relyt/auth";

const authUrl = process.env.NEXT_PUBLIC_RELYT_AUTH_URL!;
const KEY = "notes.session-token";

export const authClient = createAuthClient({
  authUrl,
  sessionToken: () => localStorage.getItem(KEY),
  onSessionToken: (t) => localStorage.setItem(KEY, t),
});

export async function emailAuth(mode: "sign-in" | "sign-up", email: string, password: string) {
  const body = mode === "sign-up" ? { email, password, name: email.split("@")[0] } : { email, password };
  const res = await fetch(`${authUrl}/api/auth/${mode}/email`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
    credentials: "omit",
  });
  if (!res.ok) throw new Error(`${mode} failed (HTTP ${res.status})`);
  const token = res.headers.get("set-auth-token");
  if (!token) throw new Error("No session token returned. Is this origin a trusted origin?");
  localStorage.setItem(KEY, token);
}

export async function api(path: string, init: RequestInit = {}) {
  const jwt = await authClient.getToken(); // en caché; se renueva antes de expirar
  const headers = new Headers(init.headers);
  headers.set("authorization", `Bearer ${jwt}`);
  return fetch(path, { ...init, headers });
}

export async function signOut() {
  const token = localStorage.getItem(KEY);
  if (token) {
    // Termina la sesión en la URL de autenticación, no solo en esta pestaña
    await fetch(`${authUrl}/api/auth/sign-out`, { method: "POST", headers: { authorization: `Bearer ${token}` }, credentials: "omit" }).catch(() => undefined);
  }
  localStorage.removeItem(KEY);
  authClient.clear();
}

Cómo encajan las piezas:

  • emailAuth envía los datos a la URL de autenticación. La respuesta trae un token de sesión en el encabezado set-auth-token. Ese token es la sesión del usuario: quien lo tenga puede obtener JWT y llamar a los endpoints de la URL de autenticación como ese usuario hasta que la sesión termine, así que trátalo como una contraseña.
  • authClient.getToken() intercambia el token de sesión por un JWT (15 minutos, EdDSA) y lo guarda en caché, renovándolo unos dos minutos antes de que expire.
  • api() envía el JWT a tu API. El navegador nunca verifica tokens; eso le toca al servidor.

Guardar el token de sesión en localStorage mantiene esta guía corta, pero cualquier script que se ejecute en tu página puede leer lo que se guarda ahí, así que un bug de cross-site scripting lo expondría. Mantén los scripts de terceros fuera de las páginas que manejan el inicio de sesión y usa una Content Security Policy estricta. El ejemplo TODO muestra una configuración más robusta que además pone el JWT en una cookie HttpOnly para la navegación entre páginas.

Una página mínima para probarlo:

// app/page.tsx
"use client";
import { useState } from "react";
import { api, emailAuth } from "@/lib/auth-client";

export default function Home() {
  const [notes, setNotes] = useState<{ id: string; body: string }[]>([]);
  const load = async () => setNotes(await (await api("/api/notes")).json());

  return (
    <main>
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          const f = new FormData(e.currentTarget);
          await emailAuth("sign-up", String(f.get("email")), String(f.get("password")));
          await load();
        }}
      >
        <input name="email" type="email" required />
        <input name="password" type="password" required />
        <button>Sign up</button>
      </form>
      <button onClick={async () => { await api("/api/notes", { method: "POST", body: JSON.stringify({ body: "hello" }) }); await load(); }}>
        Add note
      </button>
      <ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
    </main>
  );
}

Ejecuta npm run dev, registra dos usuarios en dos perfiles del navegador y agrega una nota con cada uno. Cada usuario solo ve las suyas.

Paso 10: Comprueba el aislamiento desde la terminal

Verlo en un navegador está bien. Verlo negar el acceso cuando falta algo es mejor. Obtén un JWT para un usuario con curl:

AUTH=https://<tu URL de autenticación>
ORIGIN=http://localhost:3000

SESSION=$(curl -s -D - -o /dev/null "$AUTH/api/auth/sign-in/email" \
  -H "origin: $ORIGIN" -H "content-type: application/json" \
  -d '{"email":"alice@example.com","password":"<password>"}' \
  | awk 'tolower($1)=="set-auth-token:" {print $2}' | tr -d '\r')

JWT=$(curl -s "$AUTH/api/auth/token" -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/')

curl -s http://localhost:3000/api/notes -H "authorization: Bearer $JWT"

Luego prueba lo que debería fallar:

# Sin token: 401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes

# Un token alterado: 401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes -H "authorization: Bearer ${JWT}x"

Y comprueba directamente la parte del trato que le toca a la base de datos. Conectado como notes_app, sin definir app.user_id, la tabla está vacía sin importar cuántas notas existan:

SELECT count(*) FROM notes;            -- 0
BEGIN;
SELECT set_config('app.user_id', '<sub de Alice>', true);
SELECT count(*) FROM notes;            -- solo las notas de Alice
COMMIT;

Si la primera consulta devuelve todas las notas, tu aplicación está conectada con un rol que omite RLS. Vuelve al paso 4.

Para ir más allá

Inicio de sesión social

startSocialSignIn envía al usuario a GitHub o Google a través de la URL de autenticación, y completeSocialSignIn termina en tu página de callback usando un código de traspaso de un solo uso y PKCE, así que funciona sin cookies de terceros. El panel Integration code (código de integración) de la consola tiene ambos fragmentos, ya completados con las URL de tu rama. El README del ejemplo TODO enumera las reglas para la página de callback (debe ejecutar completeSocialSignIn antes que cualquier otra cosa, y el inicio de sesión debe empezar y terminar en el mismo origen).

Equipos, roles y permisos

Cuando un usuario tiene una organización activa, su JWT lleva un claim o:

"o": { "id": "…", "slug": "acme", "role": "member", "perms": ["todo:create", "todo:read"] }

Define recursos y acciones en Roles & permissions (roles y permisos) en la consola, comprueba o.perms en tu API antes de cada operación y agrega una segunda política de RLS basada en una columna org_id (defínela con otro set_config en withUser) para las filas compartidas. El ejemplo TODO implementa exactamente esto, con todas las decisiones de permisos en un solo módulo.

Solución de problemas

SíntomaCausa probable
El inicio de sesión en el navegador falla con un error de redEl origen de la aplicación no está en Trusted origins, o tiene una ruta o una barra final
Todas las llamadas a la API devuelven 500 y el log del servidor muestra un error de config de @relyt/authRELYT_AUTH_ISSUER no es un origen sin más: tiene una barra final o una ruta, o usa http con un host que no es localhost
Todas las llamadas a la API devuelven 401 con un token que parece válidoRELYT_AUTH_ISSUER es un origen distinto del que emitió el token, por ejemplo la URL de autenticación de otra rama
La API devuelve 503No se pudo obtener el JWKS desde el servidor. Revisa el acceso de red saliente del servidor a la URL de JWKS
La conexión a la base de datos falla o se queda colgadaA la cadena de conexión le falta ?sslmode=require
Los usuarios pueden ver las filas de otros cuando una consulta olvida su filtroLa aplicación se conecta con un rol con BYPASSRLS (lo tienen los usuarios creados desde la consola y relytdb_owner). Usa el rol del paso 4
Las consultas no devuelven ninguna filaapp.user_id no se definió en la misma transacción que la consulta, o la consulta se ejecutó fuera de withUser
Una solicitud ve las filas de otro usuario, de forma intermitenteset_config se llamó con false (para toda la sesión) en una conexión del pool. Usa true

Antes de salir a producción

  • La aplicación se conecta con un rol NOBYPASSRLS dedicado (no relytdb_owner), y rolbypassrls es f.
  • Cada tabla con datos de usuarios tiene RLS activado y forzado, con USING y WITH CHECK.
  • Cada route handler llama a requireUser, incluso detrás de un middleware.
  • Los ids de usuario salen solo de claims.sub.
  • El cierre de sesión llama a /api/auth/sign-out de la URL de autenticación, y no se ejecutan scripts de terceros en las páginas que guardan el token de sesión.
  • Una caída del JWKS responde 503, no 401.
  • Los orígenes de producción están en Trusted origins, y las URL de callback de producción están registradas de forma exacta.
  • Si guardas el JWKS en caché por tu cuenta, la caché dura menos de 960 segundos y vuelves a descargarlo cuando un token trae un id de clave desconocido, para que las rotaciones de claves funcionen. (@relyt/auth ya hace ambas cosas).
  • Sabes que revocar las sesiones de un usuario en la consola deja válidos los JWT ya emitidos durante hasta 15 minutos, y tu aplicación maneja los casos en que eso importa.

Próximos pasos

Crear un proyecto gratis

EtiquetasAutenticaciónPostgresSeguridadnextjs

Escrito por

Zhan Lu

Tu próxima base de datos está a una rama de distancia.

Crea un proyecto gratis en menos de un minuto. Sin tarjeta de crédito ni configuración.

Volver al blog