For most of last year, the cheapest way to ship a security regression in a TypeScript API was a single typo:
@Roles('owener', 'admin') // shipped. nobody noticed. forever-403.
@Roles accepted any string. Tests still passed because they used the spelled-correctly literal. The route silently rejected every request. KickJS v5 fixes this: @Roles is now narrowed by your project's own role union via a one-file declare module augmentation, so misspellings become a compile error before they leave the editor. This post walks the full auth surface in KickJS — AuthAdapter, the AuthStrategy interface, @Authenticated, @Roles, @Public — at a conceptual level, with small generic snippets you can map onto any KickJS app.
The shape of auth in KickJS
KickJS keeps auth deliberately small. There are three concepts to learn, and they compose cleanly.
-
AuthAdapter— anAppAdapter(the same plug used for observability, swagger, db) constructed viacreateAuthAdapter(). You hand it adefaultPolicy('protected'or'public') and an array of strategies. It registers a per-request middleware that runs strategies in order, attaches the resolved user toreq.user, and returns 401 on failure. -
AuthStrategy— an interface:name: stringplusvalidate(req): Promise<AuthUser | null>. Built-ins live in@forinda/kickjs-auth(JwtStrategy,ApiKeyStrategy); custom strategies are just classes implementing the interface. -
Decorators —
@Authenticated('strategy-name')opts a controller or route into a specific strategy;@Roles(...)narrows the user's role;@Public()marks a route as anonymous. They compose at any level — controller-class, method, or both.
The big idea: strategy choice and role check are separate axes. @Authenticated says who is allowed to identify you; @Roles says what they need to be. Some routes only need the first (a synthetic system user has no roles); some need both (regular CRUD needs JWT plus an authorized role).
Wiring AuthAdapter
A typical adapter file looks like this:
export function createAuthAdapter(): AppAdapter {
return AuthAdapter({
defaultPolicy: 'protected',
strategies: [
JwtStrategy({ /* ...covered next... */ }),
new ServiceTokenStrategy(),
],
})
}
Two production-grade habits to copy:
-
defaultPolicy: 'protected'. Every route requires a valid user unless explicitly@Public(). The opposite default ('public') is the path to forgetting@Authenticatedon a sensitive endpoint and shipping it. Default-deny costs you@Public()on three routes (health, login, refresh) and protects everything else automatically. - Refuse to construct against an unresolved secret. If your env layer keeps secrets symbolic in committed config and a resolver swaps them at boot, the adapter must throw when it finds a placeholder value — otherwise it would silently sign or verify with the literal placeholder string. Steal the pattern: any factory that consumes a secret should refuse to build against an unresolved sentinel.
The adapter is a factory, not a class. KickJS auth v4 dropped construct signatures on AuthAdapter and JwtStrategy; calling them with new raises TS7009. If you're migrating from v3, that's the diff to make.
JWT with claim mapping
JwtStrategy does the heavy lifting — Bearer parsing, signature verify, expiry. The interesting bit is mapPayload, which turns a verified JWT payload into your app's user shape:
JwtStrategy({
secret: env.JWT_SECRET,
algorithms: ['HS256'],
mapPayload: (payload): AppAuthUser => {
if (payload.iss !== env.JWT_ISSUER) throw new Error('iss mismatch')
const claimed = Array.isArray(payload.roles) ? payload.roles : []
const roles = claimed.filter((r): r is AppRole => KNOWN_ROLES.has(r))
return { id: String(payload.sub ?? ''), roles }
},
})
Three things worth flagging:
Issuer/audience enforcement inside mapPayload. Some JwtStrategy versions don't forward issuer/audience to the underlying verifier. The workaround is small: do the comparison yourself and throw on mismatch. Throwing inside mapPayload bubbles up as a strategy failure — the adapter returns 401, the handler never runs. If a future version adds first-class options, the migration is deleting a few lines.
Role filtering as defense in depth. Filter the JWT's roles claim through your known role union — drop anything else silently. Real validation belongs at issue time (the login path checks against your membership store), but defense in depth is cheap here and stops a class of bugs where downstream code accidentally trusts an unknown role string.
Returning a typed user. The return type extends AuthUser with whatever your app needs (id, email, a typed roles array, maybe a jti for revocation). Because of the augmentation we'll see next, downstream @Roles(...) calls share that exact union.
A second strategy for a second audience
Sooner or later you have routes that aren't part of your normal user-token flow — service-to-service callers, a control plane, a CI bot, an internal admin endpoint. Stuffing them into the same JWT issuer means either issuing privileged tokens out of the user login path or carrying a flag in user tokens that breaks isolation. Don't do either. Add a second strategy.
A minimal shared-token strategy looks like this:
export class ServiceTokenStrategy implements AuthStrategy {
readonly name = 'service-token'
async validate(req: Request): Promise<AppAuthUser | null> {
const expected = (process.env.SERVICE_TOKEN ?? '').trim()
if (!expected) return null
const header = req.headers?.authorization ?? ''
if (!header.toLowerCase().startsWith('bearer ')) return null
const provided = Buffer.from(header.slice(7).trim())
const want = Buffer.from(expected)
if (provided.length !== want.length) return null
return timingSafeEqual(provided, want)
? { id: 'service', roles: [] }
: null
}
}
A few notes worth internalizing:
-
timingSafeEqual. Constant-time comparison stops the trivial timing attack on simple-token auth. If you're going to ship a shared bearer at all, do this. -
Read
process.envdirectly when you want HMR and test-stubs to "just work". Cached env snapshots are great for app config but get in the way for strategies that need to reflect runtime changes during tests. -
Synthetic users are fine. A service caller doesn't need a row in your users table. A constant
{ id: 'service', roles: [] }is enough — the strategy is the gate.
Now look at registration order:
strategies: [
JwtStrategy({ /* ... */ }),
new ServiceTokenStrategy(),
],
Order matters. KickJS picks "the first strategy that returns a user." JWT runs first, so user routes resolve their JWT-derived user. The service strategy only matches when a route opts in via @Authenticated('service-token'):
@Controller()
@Authenticated('service-token')
export class InternalOpsController { /* ... */ }
The combination of (default-deny + ordered strategies + opt-in @Authenticated('strategy-name')) is what keeps the two audiences cleanly separated.
Type-safe @Roles via AuthUser augmentation
Here's the piece that earns its keep. @Roles(...) in KickJS v5 is generic over AuthUser['roles'][number]. Inside @forinda/kickjs-auth the relevant type is roughly:
type Role = AuthUser['roles'][number]
function Roles(...roles: Role[]): MethodDecorator & ClassDecorator
By default AuthUser['roles'] is string[], so Role resolves to string — which is why old @Roles('typo') compiled. The fix is a one-file declaration merge in your app:
// src/types/auth-augment.d.ts
import type { AppRole } from '@/auth/roles'
declare module '@forinda/kickjs-auth' {
interface AuthUser {
roles: AppRole[]
}
}
export {}
Define your role union in exactly one place:
// src/auth/roles.ts
export type AppRole = 'owner' | 'admin' | 'editor' | 'viewer'
After the augmentation, AuthUser['roles'] is AppRole[] everywhere — including inside the framework, including inside the Roles decorator's generic. The package uses an IsAny-pivoted conditional to pick this up cleanly: if user code augments AuthUser, Role resolves to your union; if not, it falls back to string so unconfigured projects still compile.
Concretely:
@Controller()
@Authenticated('jwt')
export class PostsController {
@Post('/')
@Roles('owner', 'admin')
async create() { /* ... */ }
}
@Roles('owner', 'admin') typechecks. @Roles('owener', 'admin') doesn't — TS reports Argument of type '"owener"' is not assignable to parameter of type 'AppRole'.
That's the proof. The same typo that used to ship is now a red squiggle in the IDE and a CI failure on pnpm typecheck. The cost was a six-line .d.ts file. The payoff: adding a new role is one line in roles.ts, and TypeScript tells you every controller that already references it correctly — and refuses to compile any that misspell it.
Two small bits of polish that make this nicer to live with:
-
Make the augmentation visible to
tsc. Drop a side-effect import insrc/index.ts(or in a barrel re-exported by it) so the file is part of the compilation unit.tscwon't apply adeclare moduleit never sees. -
Keep the union in one file. Import the role type everywhere from the same module — including inside the
mapPayloadfilter. When the union grows, adding a literal updates the filter, the augmentation, and every@Rolescall site in lockstep.
Where @Roles doesn't belong
The service-token controller above used @Authenticated('service-token') and no @Roles(...). That's deliberate. The synthetic service user has roles: []. If you slapped @Roles('admin') on a method, the check would fail — the synthetic user has none of your normal roles, by design.
Privileged-channel gating is the strategy choice itself: passing the strategy is the authorization. Your normal role union stays scoped to user-facing routes, which keeps two concerns from leaking into each other. If you later need richer role semantics on the privileged channel, give it its own union and its own decorator (@ServiceRoles(...)) kept disjoint from the user role type on purpose.
The takeaway for your own designs: not every protected route needs @Roles. If the strategy can only ever produce one kind of user, the strategy is the gate.
Putting it together
The mental model fits on one index card:
- Pick a default policy. Default-deny unless you have a very good reason.
- List your audiences. Each audience is one strategy with one
name. - Order them by which one should win when more than one could match.
- On controllers, pick the audience with
@Authenticated('name'). On@Public()routes, opt out explicitly. - On routes, pick the role with
@Roles(...)— but only when the strategy actually issues users with roles. - Augment
AuthUser['roles']once with your real role union. Now misspellings are a build error.
Auth surfaces are usually where frameworks accumulate accidental complexity, and most of the bugs that result are cheap to ship and expensive to find. KickJS keeps the surface narrow, makes the safe defaults the easy ones, and pushes role correctness from runtime into the compiler. After you've lived with the augmentation for a sprint, dropping back to "any string is a valid role" feels reckless.
Top comments (0)