Documentation/titan/titan-auth
In this section

titan-auth

official · @omnitron-dev/titan-auth · stable

JWT authentication with HS256 / RS256 / ES256 support, remote JWKS, multi-key rotation via the kid header, an LRU token cache, signed-URL service, and Bearer/API-key middleware. Single self-contained module that issues, verifies, caches, and rotates.

pnpm add @omnitron-dev/titan-auth

When you need it

  • Inbound API auth. Validate Bearer tokens or API keys on every RPC call without re-decoding on the hot path.

  • Internal service-to-service. Use the service-key path for trusted machine clients.

  • Signed URLs. Time-bounded, signed download / webhook URLs that don't require server-side session lookup.

  • Zero-downtime key rotation. Roll signing keys without forcing re-login via the kid header registry.

Quickstart

HS256 (symmetric — simplest)

import { TitanAuthModule } from '@omnitron-dev/titan-auth';

@Module({
  imports: [
    TitanAuthModule.forRoot({
      algorithm:    'HS256',
      jwtSecret:    env.JWT_SECRET,
      issuer:       'my-app',
      audience:     'my-api',
      cacheEnabled: true,
      cacheMaxSize: 1_000,
      cacheTTL:     300_000,           // 5 min
    }),
  ],
})
class AppModule {}

RS256 / ES256 with JWKS (external IDP)

TitanAuthModule.forRoot({
  algorithm: 'RS256',
  jwksUrl:   'https://auth.example.com/.well-known/jwks.json',
  issuer:    'https://auth.example.com',
  audience:  'my-api',
})

The JWKS is lazy-fetched on first verify and cached.

Async configuration

TitanAuthModule.forRootAsync({
  imports:    [ConfigModule],
  useFactory: (config: ConfigService) => ({
    algorithm: 'HS256',
    jwtSecret: config.get('auth.jwtSecret'),
    issuer:    config.get('auth.issuer'),
  }),
  inject: [ConfigService],
})

IAuthModuleOptions

OptionTypeDefault
algorithm'HS256' | 'RS256' | 'ES256''HS256'
jwtSecretstring—
jwksUrlstring—
issuerstring—
audiencestring—
serviceKeystring — service-level API key—
anonKeystring — anonymous API key—
verificationKeysRecord<string, string> — kid → secret registry for HS256 rotation—
requireKidboolean — reject HS256 tokens without kid headerfalse
defaultTenantIdstring'default'
cacheEnabledbooleantrue
cacheMaxSizenumber1_000
cacheTTLnumber (ms)300_000
urlSigningKeystring (falls back to jwtSecret)—
isGlobalbooleanfalse

JWTService

The injected service for verification, context creation, and signed URL issuing.

import { JWT_SERVICE_TOKEN, type IJWTService } from '@omnitron-dev/titan-auth';

@Service({ name: 'admin' })
class AdminService {
  constructor(@Inject(JWT_SERVICE_TOKEN) private readonly jwt: IJWTService) {}

  @Public()
  async whoIs(token: string) {
    return this.jwt.createContext(token);
  }
}

Token verification

MethodReturnsNotes
verify(token)Promise<IJWTPayload>Cache-first; throws TokenExpiredError / InvalidTokenError
createContext(token)Promise<IAuthContext>Convenience — verify + wrap into auth context
clearCache()voidDrop every cached token
getCacheStats()ITokenCacheStats{ size, maxSize, hits, misses, hitRate }

Signed URLs

MethodReturns
createSignedToken(payload, expiresInSeconds)Promise<string>
verifySignedToken(token)Promise<ISignedTokenPayload>
const token = await this.jwt.createSignedToken(
  { resource: `download/${fileId}`, userId },
  3600,                              // 1 hour expiry
);

const url = `https://api.example.com/files/${fileId}?token=${token}`;

AuthMiddleware

Extracts credentials from an incoming request, validates them, and returns an IAuthContext.

import { AUTH_MIDDLEWARE_TOKEN, type IAuthMiddleware } from '@omnitron-dev/titan-auth';

@Service({ name: 'users' })
class UsersService {
  constructor(@Inject(AUTH_MIDDLEWARE_TOKEN) private readonly auth: IAuthMiddleware) {}

  @Public()
  async whoami(request: IRequestLike) {
    return this.auth.authenticate(request);   // anonymous fallback if no credentials
  }

  @Public()
  async listMine(request: IRequestLike) {
    return this.auth.authenticateRequired(request);   // throws UnauthorizedError if anonymous
  }
}
MethodReturnsNotes
authenticate(request)Promise<IAuthContext>Falls back to anonymous if no credentials present
authenticateRequired(request)Promise<IAuthContext>Throws UnauthorizedError if no valid credentials
extractToken(request)string | nullPulls Bearer token from headers
validateApiKey(apiKey){ valid, type?, context? }Constant-time comparison — defends against timing attacks

The auth chain: Bearer token (JWT) → service-key API key → anon-key API key → anonymous context.

Decorators

import {
  RequireAuth,
  RequireServiceAuth,
  RequireAdminAuth,
  RequireRole,
  Public,
} from '@omnitron-dev/titan-auth';

@RequireAuth(options?)

@Public()
@RequireAuth({ roles: ['admin'], allowAnonymous: false })
async deleteUser(request: IRequestLike, userId: string) {
  // this.__authContext__ is set by the decorator after successful auth
}
OptionTypeDefault
rolesstring[]—
allowAnonymousbooleanfalse
messagestring(default 401)

The decorator looks up the middleware on the instance under __authMiddleware__; inject it there. After successful auth, the resolved context is stored on the instance as __authContext__.

Shorthands

DecoratorEquivalent
@RequireServiceAuth()@RequireAuth({ roles: ['service_role'] })
@RequireAdminAuth()@RequireAuth({ roles: ['admin', 'service_role'] })
@RequireRole(roles, msg?)@RequireAuth({ roles, message: msg })
@Public()Skip auth on this method or class

Multi-key rotation (HS256)

Rotate signing secrets without forcing re-login by registering multiple keys keyed by kid:

TitanAuthModule.forRoot({
  algorithm: 'HS256',
  jwtSecret: env.JWT_SECRET_CURRENT,                 // for new tokens
  verificationKeys: {
    '2025-q1': env.JWT_SECRET_OLD_Q1,               // accept tokens signed with old key
    '2025-q2': env.JWT_SECRET_CURRENT,
  },
  requireKid: false,                                  // accept tokens without kid (legacy)
})

Resolution order on verify:

  1. RS256/ES256 via JWKS if jwksUrl is configured.

  2. HS256 with kid header → look up verificationKeys. Unknown kid rejects.

  3. HS256 without kid → legacy single-secret path, unless requireKid: true.

After every active token has expired, drop the retired key from verificationKeys to complete the rotation.

Auth flow

flowchart LR
  Req[Incoming request]
  Req --> Mw[AuthMiddleware.authenticate]
  Mw --> Bearer{Bearer token?}
  Bearer -- yes --> Verify[JWTService.verify]
  Verify -- valid --> Ctx[IAuthContext]
  Verify -- invalid --> Err1[UnauthorizedError]
  Bearer -- no --> ApiKey{API key?}
  ApiKey -- service --> CtxSvc[service-role context]
  ApiKey -- anon --> CtxAnon[anonymous context]
  ApiKey -- none --> AnonFallback{authenticate or required?}
  AnonFallback -- authenticate --> CtxAnon
  AnonFallback -- required --> Err1

Tokens

TokenPurpose
JWT_SERVICE_TOKENJWTService
AUTH_MIDDLEWARE_TOKENAuthMiddleware
SIGNED_URL_SERVICE_TOKENJWTService (same instance, narrower interface)
AUTH_OPTIONS_TOKENResolved options bundle

Lifecycle

Neither service implements explicit lifecycle hooks. JWKS is lazy-loaded; the token cache is in-memory and trims itself via LRU eviction.

Anti-patterns

  • Long cacheTTL with frequent token revocation. Revocation bypasses the cache only after cacheTTL expires. For high-churn revocation lists, lower cacheTTL or call clearCache() on revocation events.

  • Reading jwtSecret from a checked-in config. Always inject via env or a secret manager.

  • Skipping issuer and audience claims. Without them, a token issued for one tenant validates against another. Always pin both.

  • Using serviceKey from a browser. The service key bypasses user-scoped policies. Reserve it for trusted machine clients.

See also

  • Netron Authentication — the policy framework (BuiltInPolicies, PolicyEngine) for fine-grained authorisation

  • @RequireAuth — vs @Public({ auth: … }) (the inline form on @Public)

Shared sessions across backends

The shipped shared-session preset configures JWT claim validation, session-registry checks and an optional canonical-store fallback. Read it before composing a multi-backend authentication system.

Portable role and permission policy

Use @omnitron-dev/access for shared browser/server policy without installing JWT infrastructure. titan-auth/access remains a compatibility facade. The JWT module and shared-session preset are optional adapters.