En el artículo anterior dejé el backend de TrueRate con sus seis CRUDs conectados a PostgreSQL: clients, projects, time_entries, payments, fixed_costs y project_expenses. Cada módulo podía leer y escribir en su tabla, validado y listo para crecer.
Pero había un problema evidente: cualquiera podía acceder a todos esos endpoints. Sin autenticación, no había forma de saber quién creaba un proyecto o si esos datos eran de un usuario u otro.
En este post te cuento cómo implementé la autenticación de TrueRate con NestJS: JWT firmado, contraseñas hasheadas con bcrypt, el token viajando en una cookie HttpOnly y guards para proteger la API.
1. El problema: HTTP no recuerda quién eres
HTTP es un protocolo sin estado, cada petición al servidor es independiente de las anteriores. El servidor no recuerda si ya hiciste login ni quién eres.
Para resolverlo, el servidor necesita una forma de identificar al usuario en cada petición. La solución moderna es el JWT (JSON Web Token), un token que se firma con una clave secreta y que el servidor puede verificar en cada request, sin consultar la base de datos.
El token tiene tres partes separadas por puntos:
- Header → qué algoritmo se usó para firmar.
- Payload → la información del usuario (en nuestro caso
user_idyemail). - Signature → la firma digital creada con el secreto.
Una aclaración clave, el JWT se firma, no se cifra. El payload se puede leer (por eso nunca metes contraseñas ahí), pero no se puede falsificar sin el secreto, si alguien altera el payload, la firma deja de cuadrar.
El secreto es, por tanto, la pieza más sensible del sistema. Se genera con openssl rand -hex 32 y se guarda en el .env (validado con Joi, como el resto de variables), nunca en el código ni en Git.
2. ¿Dónde guardar el token? Cookie HttpOnly vs header Authorization
Hay dos formas habituales de transportar un JWT en cada petición, y la elección tiene implicaciones de seguridad:
Header Authorization: Bearer <token>:
- El frontend recibe el token en el login, lo guarda (normalmente en
localStorage) y lo añade manualmente al header de cada petición. - Problema:
localStoragees accesible por JavaScript. Si un atacante inyecta código en la página (ataque XSS), puede leer el token y robarlo.
Cookie HttpOnly:
- El servidor guarda el token en una cookie con el flag
HttpOnly, y el navegador la envía automáticamente en cada petición. - El frontend nunca toca el token: ni lo guarda, ni lo lee, ni lo añade. El código JavaScript ni siquiera puede verlo (eso hace
HttpOnly).
Elegí la cookie por esa razón, el token no pasa por las manos del frontend. El servidor lo setea, el navegador lo guarda en un sitio que el JS no puede leer y lo reenvía solo.
Los flags de la cookie
La cookie no se configura a lo bruto: cada flag protege contra un ataque concreto.
res.cookie('token', token, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1 hora
});
httpOnly→ protege contra el robo del token por XSS, el JavaScript no puede leer la cookie.secure→ protege contra la intercepción en la red, la cookie solo viaja por HTTPS.sameSite→ protege contra CSRF (peticiones de otros sitios), la cookie no se envía a sitios ajenos.
El flag secure se activa solo en producción (leyendo NODE_ENV), porque en desarrollo usamos http://localhost sin HTTPS.
3. El flujo de autenticación
Registro
El registro hashea la contraseña con bcrypt y crea las dos piezas del usuario: auth_credentials (la caja fuerte con email y hash) y users (el perfil de negocio). Recuerda la relación 1:1 del modelo, users.user_id es el mismo UUID que auth_credentials.auth_id.
const pass_hash = await bcrypt.hash(dto.password, 10);
const credentials = await this.prisma.auth_credentials.create({
data: { email: dto.email, pass_hash },
});
await this.prisma.users.create({
data: {
user_id: credentials.auth_id, // relación 1:1
name: dto.name,
},
});
Nunca guardamos la contraseña en texto plano: solo su hash, y la verificación se hace con bcrypt.compare.
Login
const token = await this.jwtService.signAsync({
user_id: credentials.auth_id,
email: credentials.email,
});
return { access_token: token };
El JwtService de @nestjs/jwt firma el payload con el JWT_SECRET (leído del ConfigService) y devuelve el token, que el controlador coloca en la cookie.
4. Validar el token: la estrategia JWT
Para proteger las rutas, usamos Passport con una estrategia JWT. La estrategia es la pieza que enseña a Passport cómo validar el token:
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
return request?.cookies?.token ?? null; // lee el token de la cookie
},
]),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
return { user_id: payload.user_id, email: payload.email };
}
}
El flujo es: la estrategia lee el token de la cookie, la librería verifica la firma con el secreto (y la expiración), y si es válido llama a validate, cuyo resultado queda en req.user. Si el token no es válido, validate ni se ejecuta, Passport rechaza la petición.
5. El guard: la puerta de las rutas protegidas
Con la estrategia lista, el guard la pone a trabajar en las rutas. Es la “puerta de entrada” entre la petición y el endpoint:
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Y se aplica a todo un controlador para proteger todas sus rutas de una vez:
@UseGuards(JwtAuthGuard)
@Controller('clients')
export class ClientsController { ... }
El guard se aplica a los CRUDs de negocio (clients, projects, time_entries, payments, fixed_costs, project_expenses), que solo deben ser accesibles con sesión. Las rutas de auth (register, login, logout) quedan públicas — si no, nadie podría registrarse ni hacer login.
6. El error que me enseñó la inyección de dependencias
Aquí quiero contar un error real que me costó un rato, porque es el tipo de problema que te hace entender de verdad cómo funciona NestJS.
Al usar el guard fuera de AuthModule (en los módulos de los CRUDs), Nest lanzaba un error, el guard no encontraba PassportModule. La causa PassportModule solo estaba importado dentro de AuthModule, pero el guard se instanciaba dentro de ClientsModule o FixedCostsModule, donde Passport no era visible.
La solución fue registrar PassportModule como global:
@Global()
@Module({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
exports: [PassportModule],
})
export class PassportGlobalModule {}
Con @Global(), Passport está disponible en toda la aplicación, donde sea que se instancie el guard. La lección, para usar un provider fuera de su módulo, debe estar en providers, en exports, el módulo consumidor debe importarlo y sus dependencias deben estar visibles en el contexto donde se usa.
7. Lo que aprendimos del flujo completo
El flujo de autenticación de TrueRate quedó así:
1. Registro: bcrypt hash → auth_credentials + users (1:1)
2. Login: verifica credenciales → firma JWT → cookie HttpOnly
3. Cada petición: el navegador envía la cookie
4. La estrategia lee y verifica el token (firma + expiración)
5. El guard deja pasar (o rechaza con 401)
6. Las rutas protegidas leen req.user (el usuario autenticado)
Con la autenticación en marcha, el siguiente paso es conectar el user_id del token a los CRUDs, que cada freelancer solo vea y gestione sus propios datos (clients, projects, pagos…), no los de los demás. Ese control de ownership es el que convierte TrueRate en una aplicación multi-usuario de verdad.