DEV Community

Cover image for Building Production-Grade Authentication with kickjs Every Mistake You'll Make
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Building Production-Grade Authentication with kickjs Every Mistake You'll Make

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 })
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
})
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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)
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

The resolution order:

  1. Method @Public() — highest priority (skip auth)
  2. Method @Authenticated() / @Roles() — require auth
  3. Class @Authenticated() — require auth for all methods
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 test runs 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

  1. Test the unhappy paths. 401 vs 403 matters. Expired tokens, wrong secrets, missing headers — test them all.
  2. CSRF + API-first is tricky. Login endpoints need @CsrfExempt() until the framework auto-exempts @Public() routes.
  3. Multi-tenant auth is a pipeline: Resolve tenant -> Switch context -> Authenticate -> Authorize with tenant-scoped roles. Each step depends on the previous one.
  4. JWT carries identity, not authorization. In multi-tenant systems, load permissions from the tenant's data store, not from JWT claims.
  5. Per-tenant JWT secrets are non-negotiable. One shared secret = cross-tenant token attacks.
  6. 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)