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)])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.