Authentication
Guard-based authentication with JWT, Session, Database Tokens, and more. Configure guards, protect routes, and use ctx.auth to access the user and guard methods.
Configuration
Configure guards in config/auth.ts. Each guard is a factory function called lazily per request.
import type { AuthConfig } from '@tekir/auth'
import { JwtGuard, SessionGuard, DatabaseTokenGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'
export default {
defaultGuard: 'jwt',
guards: {
jwt: () => new JwtGuard({
secret: env.APP_KEY,
expiresIn: 3600,
findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
}),
session: () => new SessionGuard({
findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
}),
api: () => new DatabaseTokenGuard({
db,
prefix: 'oat_',
expiresIn: 30 * 86400,
findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
})
}
} satisfies AuthConfigGuards
A guard extracts credentials, validates them, and returns the authenticated user. All guards throw HTTP 401 on failure.
JWT Guard
Reads a Bearer token from the Authorization header, verifies the HMAC-SHA256 signature, and resolves the user. No external JWT library needed.
Verification pins the expected algorithm: the token header must declare alg: HS256 (and typ: JWT when present), so alg: none and algorithm-confusion tokens are rejected before the signature is checked. A valid exp claim and a sub claim are both required; a token without a finite exp never validates, so JWTs cannot live forever.
import { JwtGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'
const guard = new JwtGuard({
secret: env.APP_KEY, // HMAC-SHA256 signing secret
expiresIn: 3600, // token lifetime in seconds (default 3600)
findUser: async (id) => {
return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
}
})Generate a token after login:
import type { HttpContext } from '@tekir/core'
import { service } from '@tekir/core'
import type { Auth, JwtGuard } from '@tekir/auth'
import { db, hash } from '#services'
const auth = service<Auth>('auth')
export async function login({ body, response }: HttpContext) {
const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [body.email])
if (!user || !(await hash.verify(body.password, user.password))) {
return response.unauthorized({ message: 'Invalid credentials' })
}
const { token, expiresAt } = await auth.guard<JwtGuard>('jwt').generate(user)
return response.ok({ token, expiresAt })
}
// The client sends the token on subsequent requests:
// Authorization: Bearer <token>Custom expiry and claims:
const { token, expiresAt } = await auth.guard<JwtGuard>('jwt').generate(user, {
expiresIn: 60 * 60 * 24 * 7, // 7 days
claims: { role: user.role, plan: 'pro' }
})Session Guard
Stores the user ID in the server-side session. Regenerates the session ID on login to prevent fixation attacks.
import { SessionGuard } from '@tekir/auth'
import { db } from '#services'
const guard = new SessionGuard({
sessionKey: 'auth_user_id', // key used inside the session (default 'auth_user_id')
findUser: async (id) => {
return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
}
})export async function login({ body, response, session }: HttpContext) {
const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [body.email])
if (!user || !(await hash.verify(body.password, user.password))) {
return response.unauthorized({ message: 'Invalid credentials' })
}
// Write user ID to session + regenerate session ID
await session.regenerate()
session.put('auth_user_id', user.id)
return response.redirect('/dashboard')
}Database Token Guard
Issues opaque bearer tokens. The stored value is a keyed HMAC-SHA256 of the token, peppered with your APP_KEY, so a database leak alone cannot be used to forge or replay tokens, since the key is never stored alongside the data. The plain-text token is returned once at generation time and never written to the database. The table is auto-created.
The guard requires an APP_KEY to key the HMAC. It reads config.appKey (or falls back to process.env.APP_KEY); if the key is missing or too short the guard throws a clear error at setup.
import { DatabaseTokenGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'
const guard = new DatabaseTokenGuard({
db, // Database instance
appKey: env.APP_KEY, // required: keys the stored token HMAC
// (falls back to process.env.APP_KEY)
prefix: 'oat_', // token prefix (default 'oat_')
expiresIn: 2592000, // 30 days in seconds
table: 'auth_tokens', // table name (default 'auth_tokens', auto-created)
findUser: async (id) => {
return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
}
})Migration: the stored token format changed. The hash column now holds an APP_KEY-keyed HMAC instead of a plain SHA-256, so all previously stored tokens are invalidated and must be regenerated with auth.generate(). The column schema is unchanged, only its contents. Setting APP_KEY is now mandatory for this guard.
Generate and manage tokens via ctx.auth:
// Inside a protected route, ctx.auth has the methods
export async function createToken({ auth, body, request, headers, response }: HttpContext) {
const { token, id } = await auth.generate({
name: 'Mobile App',
expiresIn: 30 * 86400,
metadata: {
ip: request.ip,
userAgent: headers['user-agent']
}
})
return response.created({ token, id })
}// List all tokens for the authenticated user
export async function listTokens({ auth, response }: HttpContext) {
const tokens = await auth.list()
return response.ok(tokens)
}
// Revoke all tokens for the authenticated user
export async function revokeTokens({ auth, response }: HttpContext) {
await auth.revokeAll()
return response.ok({ message: 'All tokens revoked' })
}Access Token Guard
The simplest bearer-token guard. You supply a verifier callback; it handles extraction and error-throwing.
import { AccessTokenGuard } from '@tekir/auth'
import { db } from '#services'
const guard = new AccessTokenGuard(
async (token: string) => {
return await db.queryOne('SELECT * FROM users WHERE api_token = ?', [token])
},
{ headerName: 'authorization', prefix: 'Bearer' }
)Basic Auth Guard
Decodes Authorization: Basic headers. Useful for internal tooling and webhooks.
import { BasicAuthGuard } from '@tekir/auth'
import { db, hash } from '#services'
const guard = new BasicAuthGuard({
verifyCredentials: async (username, password) => {
const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [username])
if (!user) return null
return (await hash.verify(password, user.password)) ? user : null
}
})Protecting Routes
Use authenticate() per-route with .use() or on a group. Multiple guards are tried in order.
import type { TekirApp } from '@tekir/core'
import { authenticate, silentAuth, guest } from '@tekir/auth'
export default function({ router }: TekirApp) {
// Protect with the default guard
router.get('/profile', showProfile).use(authenticate())
// Protect with a specific guard
router.get('/api/me', showProfile).use(authenticate('api'))
// Try multiple guards in order (first to succeed wins)
router.get('/dashboard', showDashboard).use(authenticate(['jwt', 'session']))
// Protect an entire group
router.group(() => {
router.get('/posts', listPosts)
router.post('/posts', createPost)
}).prefix('/api').use(authenticate('jwt'))
}guest() is the inverse, passes only unauthenticated users:
// Prevent authenticated users from accessing the login page
router.get('/login', showLogin).use(guest())Silent Auth
silentAuth() checks authentication without blocking. Use it on public pages that optionally show user info.
import { authenticate, silentAuth, guest } from '@tekir/auth'
// Silent auth checks if the user is logged in without blocking.
// If authenticated: auth.user is set, auth.isAuthenticated = true
// If not: auth.user = null, auth.isAuthenticated = false
router.useGlobal([silentAuth()])
// In your handler:
export function home({ auth, response }: HttpContext) {
if (auth.isAuthenticated) {
return response.ok({ greeting: `Hello ${auth.user!.name}` })
}
return response.ok({ greeting: 'Hello guest' })
}The Auth Object
After the auth middleware runs, ctx.auth has both properties and methods bound to the active guard and current user:
export async function show({ auth, response }: HttpContext) {
// Properties
auth.user // the authenticated user object
auth.isAuthenticated // true inside a protected route
auth.guard // name of the guard that succeeded ('jwt', 'session', 'api')
// Methods: bound to the active guard and current user
await auth.generate() // generate a token (JWT / DatabaseToken)
await auth.logout() // logout (clears session or revokes token)
await auth.list() // list all tokens (DatabaseToken guard)
await auth.revokeAll() // revoke all tokens (DatabaseToken guard)
return response.ok({ id: auth.user!.id, email: auth.user!.email })
}auth.user: the authenticated userauth.isAuthenticated: alwaystruein protected routesauth.guard: name of the guard that succeededauth.generate(options?): generate a token (JWT/DatabaseToken)auth.logout(): logout via the active guardauth.list(): list tokens (DatabaseToken guard)auth.revokeAll(): revoke all tokens (DatabaseToken guard)
Generating Tokens
Inside a protected route, call auth.generate(options?) directly. For login endpoints (before middleware runs), use auth.guard().generate().
Logging Out
Call auth.logout() to delegate to the active guard automatically:
// The simplest way, ctx.auth.logout() delegates to the active guard:
export async function logout({ auth, response }: HttpContext) {
await auth.logout()
return response.ok({ message: 'Logged out' })
}
// Session guard: clears session key + regenerates session ID
// JWT guard: no server-side state: client discards the token
// DatabaseToken guard: revokes the current token