Build auth with kickjs
I spent a week building 20 authentication test applications for KickJS — a decorator-driven Node.js framework on Express 5 and TypeScript. The goal: stress-test every auth pattern the framework supports and document every pitfall along the way.
Here's what I learned, what broke, and how to fix it.
What We Built
A pnpm monorepo with 20 standalone apps, each testing a different authentication strategy:
Core auth patterns (11 apps):
JWT, API keys, OAuth (Google/GitHub/Discord/Microsoft), Passport.js bridge, RBAC, multi-strategy fallback, decorator precedence, guard middleware, session-based auth, SAML 2.0, API gateway
Multi-tenant auth (8 apps):
Header/subdomain tenant resolution, database-per-tenant isolation, schema-per-tenant, shared-table discriminator, tenant-scoped RBAC, per-tenant JWT secrets, cross-tenant adversarial testing
Identity provider (1 app):
Keycloak integration with realm/client role mapping, RS256 validation
222 tests. Zero failures. Here's what went wrong getting there.
Mistake 1: Accessing the User Wrong
Every framework has a way to get the authenticated user. Django has request.user. Laravel has Auth::user(). Spring Boot has @AuthenticationPrincipal.
In KickJS, the auth adapter sets req.user on the raw Express request during the middleware phase. But the framework's RequestContext (the ctx object your controllers receive) has its own metadata store with ctx.set() / ctx.get().
What you'll write first:
@Get('/me')
@Authenticated()
me(ctx: RequestContext) {
const user = (ctx.req as any).user // ugly, untyped, breaks abstraction
ctx.json({ user })
}
Why this is wrong:
The as any cast bypasses TypeScript. You're reaching through the framework's abstraction into the raw Express request. If the framework changes how it stores the user, your code breaks silently.
The fix (coming in a future release):
// RequestContext gains a .user getter
get user(): AuthUser | undefined {
return (this.req as any).user ?? this.metadata.get('user')
}
Until then, the workaround is what every app in our test suite uses. It works. It's just not pretty.
Takeaway: If your framework wraps the request object, make sure auth data is accessible through the wrapper, not just the raw object underneath.
Mistake 2: @public Routes Returning 401
We upgraded from auth v2 to v3 and suddenly every @Public() route started returning 401.
@Controller()
@Authenticated() // class-level: all routes need auth
export class UserController {
@Get('/me')
me(ctx) { ... } // protected - correct
@Get('/health')
@Public() // should bypass auth
health(ctx) { ... } // was returning 401!
}
Root cause: The v3 package was a fresh publish where the Symbol('auth:public') identity changed. Symbols are runtime-unique — if the decorator and the adapter resolve from different module instances, the metadata keys don't match.
How we caught it: 6 of 11 apps broke simultaneously with the same pattern. That's the value of comprehensive test apps — one subtle framework change lights up failures everywhere.
The fix: The framework author patched it in v3.0.2. Our test suite confirmed the fix across all 11 apps in one command: pnpm test.
Takeaway: When you publish a package with Symbols as metadata keys, ensure there's exactly one copy in the dependency tree. pnpm ls is your friend.
Mistake 3: CSRF Blocking Your Login Endpoint
v3 added automatic CSRF protection. If you use session-based auth, it auto-enables. Smart, right?
Except it also blocks your POST /login endpoint — the one place users can't have a CSRF token yet because they haven't made a GET request to receive the cookie.
@Post('/login')
@Public() // bypasses auth check
// but CSRF middleware still runs!
login(ctx) { ... } // 403 "CSRF token mismatch"
Why it happens: CSRF middleware runs independently of auth middleware. @Public() exempts from authentication, not from CSRF. The login POST has no CSRF token because it's the first request.
The fix:
@Post('/login')
@Public()
@CsrfExempt() // explicitly skip CSRF for this route
login(ctx) { ... }
The better fix (proposed to the framework): @Public() should auto-exempt from CSRF. If there's no session to protect, CSRF is meaningless. Django, Laravel, and Spring all handle this — public endpoints don't need CSRF because there's no cookie-based session to exploit.
Takeaway: CSRF protection + API-first design = friction. Login, logout, webhooks, and OAuth callbacks all need @CsrfExempt() or equivalent. Design your CSRF middleware to understand that pre-auth endpoints have nothing to protect.
Mistake 4: Roles in JWT Don't Match Roles in Your Decorator
Keycloak stores roles like this:
{
"realm_access": {
"roles": ["admin", "user"]
},
"resource_access": {
"my-app": {
"roles": ["editor", "viewer"]
}
}
}
But KickJS's @Roles('admin') checks a flat user.roles array. If your mapPayload only extracts realm_access.roles, you'll never match client-specific roles.
What goes wrong:
// Only gets realm roles — client roles silently lost
mapPayload: (payload) => ({
id: payload.sub,
roles: payload.realm_access?.roles ?? [],
})
@Roles('editor') // never matches! 'editor' is in resource_access
The fix:
import { keycloakMapPayload } from '@forinda/kickjs-auth'
new JwtStrategy({
jwksUri: 'https://keycloak.example.com/realms/my-realm/.../certs',
mapPayload: keycloakMapPayload({ clientId: 'my-app' }),
// Merges realm_access.roles + resource_access['my-app'].roles
})
Takeaway: Identity providers have their own claim structure. Don't assume roles are in a flat array. Map them explicitly and test with real-ish JWT payloads, not simplified mocks.
Mistake 5: Rate Limit State Leaking Between Tests
We wrote a custom rate-limit guard with an in-memory counter:
const counters = new Map<string, { count: number; resetAt: number }>()
export async function rateLimitGuard(ctx, next) {
const key = ctx.req.ip || 'unknown'
// ... increment counter, reject if over limit
}
Tests started failing randomly. The 6th test in the suite would get 429 even though it was the first request in that test.
Root cause: The Map is module-level. Test isolation resets the DI container (Container.reset()) but doesn't reset module-level state.
The fix:
export function resetRateLimitCounters() {
counters.clear()
}
// In test:
beforeEach(() => {
Container.reset()
resetRateLimitCounters() // don't forget this!
})
The v3 way: Use @RateLimit({ max: 5, windowMs: 60_000 }) decorator instead. The framework manages the counters per-route inside the adapter, and createTestApp with isolated: true gets a fresh adapter.
Takeaway: Module-level mutable state is the enemy of test isolation. Export a reset function or move state into the DI container.
Mistake 6: Multi-Tenant Role Leakage
This one's a security bug. In a multi-tenant app, the same user can have different roles in different tenants. Admin in Tenant A, viewer in Tenant B.
If you store roles in the JWT, they're global:
{ "sub": "user-123", "roles": ["admin"] }
Sending this JWT with X-Tenant-ID: tenant-b grants admin access in Tenant B even though the user is only a viewer there.
The fix — tenant-scoped role resolution:
new AuthAdapter({
strategies: [new JwtStrategy({ ... })],
roleResolver: async (user, tenantId) => {
// Load roles from tenant's database, not from JWT
return tenantRoleStore.getRoles(tenantId, user.id)
},
})
The JWT carries identity (who you are). The tenant database carries authorization (what you can do here). Never trust JWT claims for tenant-scoped permissions.
How we tested it:
tenantRoleStore.setRoles('tenant-a', userId, ['admin'])
tenantRoleStore.setRoles('tenant-b', userId, ['viewer'])
// Same JWT, different tenant header
const resA = await request(app)
.get('/admin-only')
.set(withTenant('tenant-a'))
.set(withBearer(token))
// 200 - admin in tenant A
const resB = await request(app)
.get('/admin-only')
.set(withTenant('tenant-b'))
.set(withBearer(token))
// 403 - viewer in tenant B
Takeaway: In multi-tenant systems, separate authentication (identity) from authorization (permissions). JWTs prove who you are. The tenant's data store decides what you can do.
Mistake 7: Per-Tenant JWT Secrets — The Cross-Tenant Token Attack
Each tenant should have its own JWT signing secret. Otherwise, a token from Tenant A is valid in Tenant B.
new JwtStrategy({
secret: 'one-secret-for-all', // WRONG for multi-tenant
})
The attack: User in Tenant A copies their JWT. Sends it with X-Tenant-ID: tenant-b. Since the same secret validates both, Tenant B accepts it.
The fix:
const tenantSecrets = {
'tenant-a': 'tenant-a-unique-secret-...',
'tenant-b': 'tenant-b-unique-secret-...',
}
new JwtStrategy({
secretResolver: async (tenantId) => {
const secret = tenantSecrets[tenantId]
if (!secret) throw new Error(`Unknown tenant: ${tenantId}`)
return secret
},
})
Now a Tenant A token fails verification against Tenant B's secret. Our adversarial test:
const tokenA = jwt.sign(payload, tenantSecrets['tenant-a'])
const res = await request(app)
.get('/me')
.set(withTenant('tenant-b')) // wrong tenant
.set(withBearer(tokenA))
// 401 - signature mismatch
Takeaway: Multi-tenant JWT = per-tenant secrets. No exceptions.
Mistake 8: Token Revocation That Un-Revokes
We found a bug in the framework's MemoryTokenStore.revokeAllForUser(). It was supposed to revoke all tokens for a user (for password change, account compromise). Instead, it deleted the revocation entries — making previously-revoked tokens valid again.
// What the code did:
async revokeAllForUser(userId: string): Promise<void> {
for (const [key, entry] of this.revoked) {
if (entry.userId === userId) {
this.revoked.delete(key) // REMOVES the blacklist entry!
}
}
}
After calling revokeAllForUser('user-123'), isRevoked('token-abc') returns false because the entry was deleted. The token works again.
How we caught it: The test expected isRevoked to return true after revokeAllForUser. It returned false. A simple naming confusion in the implementation — "revoke all" was implemented as "clean up tracking for".
Takeaway: Always test the negative path of security functions. "This token should NOT work after revocation" is more important than "this token should work before revocation."
Mistake 9: The Subdomain Tenant That Never Resolves
The TenantAdapter's subdomain strategy checks hostname.split('.').length >= 3. So:
| Host | Parts | Resolves? |
|---|---|---|
tenant-a.api.example.com |
4 | Yes: tenant-a
|
tenant-a.localhost |
2 | No |
localhost |
1 | No |
In development, localhost has no subdomain. You need at least 3 parts: tenant-a.api.localhost.
// This works:
.set('Host', 'tenant-a.api.localhost') // 3 parts
// This doesn't:
.set('Host', 'tenant-a.localhost') // only 2 parts
Takeaway: Test your tenant resolution with realistic hostnames. localhost is not the same as app.example.com. Your local dev setup needs a wildcard DNS or /etc/hosts entries.
Mistake 10: The Decorator Precedence Trap
Which wins — class-level @Authenticated() or method-level @Public()?
@Controller()
@Authenticated() // class: everything needs auth
export class AuthController {
@Get('/me')
me(ctx) { ... } // auth required (inherits from class)
@Post('/login')
@Public() // method overrides class
login(ctx) { ... } // public (method wins)
@Delete('/:id')
@Roles('admin') // implies @Authenticated
delete(ctx) { ... } // needs auth + admin role
}
The resolution order:
- Method
@Public()— highest priority (skip auth) - Method
@Authenticated()/@Roles()— require auth - Class
@Authenticated()— require auth for all methods -
defaultPolicy— fallback for unannotated routes
And defaultPolicy matters more than you think:
// defaultPolicy: 'protected' (default)
// Unannotated routes require auth — secure by default
// defaultPolicy: 'open'
// Unannotated routes are public — you must explicitly protect each one
We tested every combination. The one that catches people: @Roles('admin') without a token returns 401 (not 403). Auth is checked first, roles second. No token = unauthorized, not forbidden.
Takeaway: @Roles implies @Authenticated. The error code tells you which check failed: 401 = identity problem, 403 = permission problem.
The Architecture That Made This Possible
We used a pnpm workspace monorepo with one app per auth pattern:
kick-auth-implementations/
├── packages/shared/ # Test helpers, assertions, mock users
├── apps/
│ ├── jwt-auth/ # 33 tests
│ ├── api-key-auth/ # 16 tests
│ ├── oauth-auth/ # 12 tests
│ ├── session-auth/ # 11 tests (CSRF tests included)
│ ├── rbac-auth/ # 31 tests (policies + events)
│ ├── keycloak-auth/ # 8 tests
│ ├── mt-tenant-isolation/ # 7 adversarial tests
│ └── ... (20 apps total)
└── issues/ # Framework bugs found along the way
Why this works:
- Isolation: A bug in OAuth tests can't break JWT tests
- Incremental: Add a new auth pattern without touching existing apps
-
CI-friendly:
pnpm testruns all 222 tests in parallel - Documentation: Each app IS the documentation for that pattern
What We Filed
Building 20 apps against a framework surfaces real issues. We filed 22 framework issues:
Security gaps: CSRF auto-exempt for @Public(), token revocation semantics bug, security-by-default bootstrap
Missing features: JWKS key rotation (landed in v3.0.4), Keycloak claim mapping (landed in v3.0.4), token introspection strategy, back-channel logout, refresh token flow
Multi-tenant gaps: DI token is static singleton (needs AsyncLocalStorage), no database connection switching, auth + tenant adapter integration
DX improvements: ctx.user bridging, auth scaffold generator, password service (landed in v3)
TL;DR
- Test the unhappy paths. 401 vs 403 matters. Expired tokens, wrong secrets, missing headers — test them all.
-
CSRF + API-first is tricky. Login endpoints need
@CsrfExempt()until the framework auto-exempts@Public()routes. - Multi-tenant auth is a pipeline: Resolve tenant -> Switch context -> Authenticate -> Authorize with tenant-scoped roles. Each step depends on the previous one.
- JWT carries identity, not authorization. In multi-tenant systems, load permissions from the tenant's data store, not from JWT claims.
- Per-tenant JWT secrets are non-negotiable. One shared secret = cross-tenant token attacks.
- Test with realistic data. Keycloak's nested role structure, subdomain hostname parts, Symbol identity across module boundaries — mocks hide these issues.
The entire test suite is open source. Every app, every test, every issue filed.
Built with KickJS — a decorator-driven Node.js framework on Express 5 and TypeScript.
Top comments (0)