Authentication
Pearl uses a guard-based auth system. A guard knows how to verify a user's identity — the built-in JwtGuard issues and validates Bearer tokens. You provide a UserProvider that tells the guard where to look up users in your database.
Once a guard is registered, protect any route by passing Authenticate(auth) as middleware. Inside the handler, ctx.get('auth.user') returns the authenticated user.
How it works
- You register a
JwtGuardwith aUserProvider(your DB logic). - On login, the guard verifies credentials and issues a signed JWT.
- On subsequent requests, the guard reads the
Authorization: Bearer ...header and loads the user. - Inside a protected handler,
ctx.get('auth.user')returns the authenticated user.
Setup
Implement UserProvider with two methods — one to find a user by ID (for token verification) and one to find a user by credentials (for login). Then register the guard inside AppServiceProvider.register().
import { JwtGuard, AuthManager, Hash, DatabaseManager } from '@pearl-framework/pearl'
import type { UserProvider } from '@pearl-framework/pearl'
import { eq } from '@pearl-framework/pearl'
import { users } from '../schema/users.js'
// Inside register():
this.container.singleton(JwtGuard, () => {
const db = this.container.make(DatabaseManager)
const userProvider: UserProvider = {
// Called when verifying a token — loads the user by their ID
async findById(id: string) {
const [row] = await db.db
.select().from(users).where(eq(users.id, Number(id)))
return row ?? null
},
// Called on login — checks email + password
async findByCredentials(email: string, password: string) {
const [row] = await db.db
.select().from(users).where(eq(users.email, email))
if (!row) return null
return await Hash.check(password, row.password) ? row : null
},
}
return new JwtGuard(userProvider, {
secret: process.env.JWT_SECRET!, // min 32 chars — use: openssl rand -base64 32
expiresIn: '7d',
})
})
this.container.singleton(AuthManager, () => {
const manager = new AuthManager()
manager.register('jwt', this.container.make(JwtGuard))
manager.setDefault('jwt')
return manager
})Login & register routes
Create an AuthController with register, login, and me methods. Note that ctx.request.body is a getter — no parentheses.
import { Hash, JwtGuard, DatabaseManager } from '@pearl-framework/pearl'
import type { HttpContext } from '@pearl-framework/pearl'
import { users } from '../schema/users.js'
export class AuthController {
constructor(
private db: DatabaseManager,
private guard: JwtGuard,
) {}
// POST /auth/register
async register(ctx: HttpContext) {
const { name, email, password } = ctx.request.body as {
name: string; email: string; password: string
}
const [user] = await (this.db.adapter as DrizzleAdapter).db
.insert(users)
.values({ name, email, password: await Hash.make(password) })
.returning()
const token = await this.guard.issueToken(String(user.id))
ctx.response.created({ token, user })
}
// POST /auth/login
async login(ctx: HttpContext) {
const { email, password } = ctx.request.body as {
email: string; password: string
}
const user = await this.guard.attempt(email, password)
if (!user) return ctx.response.unauthorized('Invalid email or password')
const token = await this.guard.issueToken(String(user.id))
ctx.response.json({ token, user })
}
// GET /auth/me — protected route
async me(ctx: HttpContext) {
ctx.response.json(ctx.get('auth.user'))
}
}Protecting routes
Pass Authenticate(auth) in the middleware array for any route that requires a logged-in user. Requests without a valid token get a 401 automatically.
const auth = app.container.make(AuthManager)
const authCtrl = app.container.make(AuthController)
const guard = [Authenticate(auth)]
// Public — no token required
router.post('/auth/register', (ctx) => authCtrl.register(ctx))
router.post('/auth/login', (ctx) => authCtrl.login(ctx))
// Protected — valid Bearer token required
router.get('/auth/me', (ctx) => authCtrl.me(ctx), guard)
router.post('/posts', (ctx) => postCtrl.store(ctx), guard)Using the token
Send the token in the Authorization header on every protected request:
curl -H "Authorization: Bearer <your-token>" \
http://localhost:3000/auth/meOptional auth
Use OptionalAuth(auth) when a route should work for both guests and authenticated users. ctx.get('auth.user') returns the user if a valid token is present, or null if not — no 401 is sent.
import { OptionalAuth } from '@pearl-framework/pearl'
router.get('/feed', async (ctx) => {
const user = ctx.get('auth.user') // AuthUser | null
ctx.response.json(buildFeed(user))
}, [OptionalAuth(auth)])Session guard (cookie-based auth)
Prefer cookies over Bearer tokens? Use SessionGuard. It issues an opaque session id you can write to a cookie, validates it on every request, and supports rotation-on-use and "log out all devices".
import { SessionGuard, AuthManager } from '@pearl-framework/pearl'
import type { SessionStore, UserProvider } from '@pearl-framework/pearl'
// Provide your own store — Redis, DB table, etc.
const store: SessionStore = {
async find(id) { /* ... */ },
async save(record) { /* ... */ },
async destroy(id) { /* ... */ },
async destroyAll(uid) { /* ... */ },
}
this.container.singleton(SessionGuard, () =>
new SessionGuard(userProvider, store, {
lifetimeSeconds: 60 * 60 * 2, // 2h
rotateOnUse: true, // issue a fresh id on every successful check
})
)// Login — issue a session and set the cookie
const id = await guard.attempt(email, password)
if (!id) return ctx.response.unauthorized()
ctx.response.header('set-cookie', `sid=${id}; HttpOnly; Secure; SameSite=Lax`)
ctx.response.ok({ ok: true })
// Logout — destroy this session
await guard.logout(id)
// "Log out everywhere" — destroy every session for the user
await guard.logoutAll(user)FormRequest authorization errors
When a FormRequest.authorize() returns false, Pearl throws a typed AuthorizationException (instead of a plain Error). Use it to distinguish auth failures from validation failures in your error-handler middleware:
import { ValidationException, AuthorizationException } from '@pearl-framework/pearl'
if (err instanceof AuthorizationException) {
return ctx.response.status(403).json(err.toJSON())
}
if (err instanceof ValidationException) {
return ctx.response.status(422).json({ errors: err.errors })
}Password hashing
Use the built-in Hash utility — bcrypt under the hood. Never store plain-text passwords.
import { Hash } from '@pearl-framework/pearl'
// Hash before storing
const hash = await Hash.make('my-password')
// Verify against a stored hash
const valid = await Hash.check('my-password', hash) // true
const wrong = await Hash.check('wrong-pass', hash) // falseSecurity notes
- Algorithm pinning —
jwt.verify()is called with an explicitalgorithmsallowlist. This prevents algorithm confusion attacks where an attacker switches the token's algorithm to bypass verification. nonealgorithm blocked — passingalgorithm: 'none'throws at construction time.- Secret strength — use a minimum of 32 random characters. Generate one with
openssl rand -base64 32.