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

  1. You register a JwtGuard with a UserProvider (your DB logic).
  2. On login, the guard verifies credentials and issues a signed JWT.
  3. On subsequent requests, the guard reads the Authorization: Bearer ... header and loads the user.
  4. 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().

src/providers/AppServiceProvider.ts
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.

src/controllers/AuthController.ts
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.

src/server.ts
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/me

Optional 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)  // false

Security notes

  • Algorithm pinningjwt.verify() is called with an explicit algorithms allowlist. This prevents algorithm confusion attacks where an attacker switches the token's algorithm to bypass verification.
  • none algorithm blocked — passing algorithm: 'none' throws at construction time.
  • Secret strength — use a minimum of 32 random characters. Generate one with openssl rand -base64 32.