Local Provider
Problem
You want a POST /auth/login endpoint that accepts { email, password }, verifies the password against an argon2 hash in the database, and on success populates ctx.user with the matched user. The handler then issues a JWT.
Why an AuthProvider and not a plain controller?
Both work. Putting the credential check in an AuthProvider gives you three things:
- Consistency - login looks like every other authenticated route (one
@UseGuardline), so reading the controller is trivial. - Composability - you can stack the local provider in a multi-provider guard (
AuthGuard(JwtAuth, LocalAuth)) for endpoints that accept either. - Reusability - the same provider works for
POST /auth/loginandPOST /auth/refresh-credentialswithout duplicating logic.
Pipeline note: provider runs before @ValidateBody
This is a critical ordering detail. MiiaJS executes guards (including AuthGuard) before the handler - and @ValidateBody runs as part of the handler's wrapping, not as a guard. So inside the provider's authenticate(), ctx.json() returns the raw, unvalidated body.
The provider must validate the body itself. Don't rely on @ValidateBody to have run yet - it hasn't.
Solution
// src/auth/providers/local-auth.provider.ts
import { Injectable, inject, UnauthorizedException, type RequestContext } from '@miiajs/core'
import type { AuthProvider } from '@miiajs/auth'
import { verify as verifyHash } from '@node-rs/argon2'
import { z } from 'zod'
import { UsersService } from '../../users/users.service.js'
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
@Injectable()
export class LocalAuth implements AuthProvider {
private usersService = inject(UsersService)
async authenticate(ctx: RequestContext) {
const raw = await ctx.json().catch(() => null)
const parsed = LoginSchema.safeParse(raw)
if (!parsed.success) {
throw new UnauthorizedException('Invalid credentials payload')
}
const { email, password } = parsed.data
const found = await this.usersService.findByEmailForAuth(email)
if (!found) throw new UnauthorizedException('Invalid credentials')
const ok = await verifyHash(found.passwordHash, password)
if (!ok) throw new UnauthorizedException('Invalid credentials')
const { passwordHash: _, ...user } = found
return user
}
}
Why
Invalid credentialsfor both missing-user and wrong-password? Don't leak which one was wrong - that's a user enumeration vector. Same error, every time.
Strip
passwordHashbefore returning. Whatever the provider returns lands onctx.user, which downstream handlers may serialize back to the client. Destructure the hash out (or build a dedicated public shape) - never let it escape the provider.
AuthService
Token issuance does not belong in the controller. Push it into a service so every route that needs a token calls the same method.
// src/auth/auth.service.ts
import { Injectable, inject } from '@miiajs/core'
import { JwtService } from '@miiajs/jwt'
@Injectable()
export class AuthService {
private jwtService = inject(JwtService)
async issueTokenFor(user: { id: number; email: string }) {
return this.jwtService.sign({ sub: user.id, email: user.email })
}
}
In a real app this service also owns register(), password reset, refresh token rotation, and so on - the controller stays thin.
Login controller
The provider has already placed the user on ctx.user. The handler only asks AuthService to mint a token.
// src/auth/auth.controller.ts
import { Controller, Post, Status, UseGuard, ValidateBody, inject, type RequestContext } from '@miiajs/core'
import { AuthGuard } from '@miiajs/auth'
import { AuthService } from './auth.service.js'
import { LocalAuth } from './providers/local-auth.provider.js'
import { RegisterSchema, type RegisterInput } from './schemas/register.schema.js'
import type { User } from '../users/users.schema.js'
@Controller('/auth')
export class AuthController {
private authService = inject(AuthService)
@Post('/login')
@UseGuard(AuthGuard(LocalAuth))
async login(ctx: RequestContext) {
const accessToken = await this.authService.issueTokenFor(ctx.user as User)
return { accessToken }
}
@Post('/register')
@Status(201)
@ValidateBody(RegisterSchema)
async register(ctx: RequestContext) {
const data = await ctx.json<RegisterInput>()
return this.authService.register(data) // hash password, persist, issue token - inside the service
}
}
Registration is not a LocalAuth use case - there's no existing user to authenticate. It's a normal route with @ValidateBody, delegating to AuthService.register().
The contrast between the two handlers is the key pipeline detail: login uses @UseGuard, so the provider consumes ctx.json() before the handler runs (that's why the provider validates the body itself). register uses @ValidateBody instead, which runs as part of the handler wrapping - no guard, no early body consumption.
Module wiring
// src/auth/auth.module.ts
import { Module } from '@miiajs/core'
import { AuthController } from './auth.controller.js'
import { AuthService } from './auth.service.js'
import { LocalAuth } from './providers/local-auth.provider.js'
@Module({
controllers: [AuthController],
providers: [AuthService, LocalAuth],
})
export class AuthModule {}
UsersService note
The provider calls users.findByEmailForAuth(email) - a dedicated method that returns the password hash. Keep a separate findById (or similar) for public reads that excludes the hash. Don't merge them, and never return the auth variant from an HTTP handler.
Security notes
Password hashing: argon2id
argon2id is the modern recommendation from OWASP. It's resistant to GPU attacks and has tunable memory cost. @node-rs/argon2 is fast (Rust-based) and works on Node, Bun, and Deno. If you have an existing bcrypt deployment, bcrypt.compare works the same way - swap the import.
Don't leak which half of the credentials is wrong
Both "user not found" and "wrong password" must return the same error (see the blockquote under Solution). Differentiating them turns your login endpoint into a user-enumeration oracle.
Never return the password hash
Whatever the provider returns lands on ctx.user, which downstream handlers may serialize back to the client (see the blockquote under Solution). Either destructure the hash out at the provider boundary, or keep a separate findByEmailForAuth / public-shape split at the UsersService level - which is exactly what the UsersService note above describes.
See also
@miiajs/auth-AuthProviderinterface,AuthGuard@miiajs/jwt-JwtService.sign()- JWT Provider - verify the token issued here on subsequent requests
- OAuth2 Provider - third-party login