Skip to content

Custom Authentication

How do I replace Supabase with my own authentication provider?

Section titled “How do I replace Supabase with my own authentication provider?”

Xeno.JS uses Supabase by default when authentication is configured, but you can replace it with your own authentication service.

A custom authentication integration has two separate responsibilities:

  1. Token extraction — retrieve the credential from the incoming HTTP request.
  2. Credential validation — validate that credential and return the authenticated user’s claims.

Configure both through customAuth:

const app = new AppBuilder()
app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new MyTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})
app.addMiddlewares()
const container = await app.build()

You do not need to configure Supabase credentials when using a custom authentication service.


With a custom provider, the request authentication flow is:

HTTP request
│
▼
Custom token extractor
│
▼
AuthenticationMiddleware
│
▼
Custom authentication service
│
▼
AuthClaims
│
▼
ClaimsIdentityMapper
│
▼
Request Identity

The extractor answers:

Where do I get the credential?

The authentication service answers:

Is this credential valid, and which identity does it represent?

Keeping these responsibilities separate allows the authentication provider and the HTTP credential format to change independently.


The authentication configuration exposes:

customAuth: {
authHeaderExtractor: () =>
IServiceExtractor<Request['headers'], string | undefined>
authExtendedService: () =>
IExtendendAuthService
}

There are two required components.

Property Responsibility
authHeaderExtractor Creates the service that extracts the credential from HTTP headers
authExtendedService Creates the authentication service that validates the credential and returns authentication data

Example:

app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new MyTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})

The extractor implements IServiceExtractor.

Its contract is:

interface IServiceExtractor<TRequest, TResponse = unknown> {
extract(request: TRequest): TResponse
}

For authentication, Xeno.JS expects an extractor with this shape:

IServiceExtractor<
Request['headers'],
string | undefined
>

The extractor receives the request headers and returns either:

  • the authentication credential;
  • undefined when no credential is present.

Suppose your provider uses:

Authorization: Token <credential>

instead of the standard bearer format.

You can implement:

import type { IServiceExtractor } from '@xeno-js/shared'
export class MyTokenExtractor
implements IServiceExtractor<Request['headers'], string | undefined>
{
extract(headers: Request['headers']): string | undefined {
const authorization = headers.authorization
if (!authorization) {
return undefined
}
if (!authorization.startsWith('Token ')) {
return undefined
}
return authorization.slice('Token '.length)
}
}

Register it through customAuth:

app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new MyTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})

The extractor should only be responsible for retrieving the credential. It should not validate the token or construct the application identity.


The custom authentication service must implement IExtendendAuthService.

IExtendendAuthService combines the base authentication contract with session/provider operations:

interface IExtendendAuthService
extends IBaseAuthService, IAuthService {}

The authentication middleware relies on these methods:

interface IBaseAuthService {
isAuthenticated(): Promise<boolean>
getUser(): Promise<ResultType<Maybe<AuthClaims>>>
authenticate(token: string): Promise<ResultType<AuthClaims>>
}

The important method for request authentication is:

authenticate(token: string): Promise<ResultType<AuthClaims>>

It receives the credential returned by the token extractor and must return the authenticated user’s claims.


A provider-specific service can implement the contract directly:

import type {
IExtendendAuthService,
ResultType,
AuthClaims,
Maybe,
Optional,
Provider,
Session,
} from '@xeno-js/shared'
export class MyAuthService implements IExtendendAuthService {
async isAuthenticated(): Promise<boolean> {
// Check whether the current authentication state is valid.
return false
}
async getUser(): Promise<ResultType<Maybe<AuthClaims>>> {
// Resolve the current authenticated user when no explicit token
// is supplied to the authentication flow.
//
// Return the provider's AuthClaims through ResultType.
throw new Error('Implement getUser()')
}
async authenticate(token: string): Promise<ResultType<AuthClaims>> {
// Validate the token with your authentication provider.
//
// Return AuthClaims through ResultType.
throw new Error('Implement authenticate()')
}
async signInWithProvider(
provider: Provider,
): Promise<ResultType<{ url: string }>> {
// Implement provider-specific sign-in if your application uses it.
throw new Error('Implement signInWithProvider()')
}
async getSession(): Promise<ResultType<Maybe<Session>>> {
// Return the current provider session.
throw new Error('Implement getSession()')
}
async signOut(): Promise<ResultType<void>> {
// Invalidate the current provider session.
throw new Error('Implement signOut()')
}
async exchangeCodeForSession(
code: string,
): Promise<ResultType<Optional<Session>>> {
// Exchange an authentication callback code for a session.
throw new Error('Implement exchangeCodeForSession()')
}
}

The exact implementation of these methods depends on your authentication provider.

The important requirement for HTTP authentication is that authenticate() returns valid AuthClaims.


Xeno.JS defines authentication claims as:

interface AuthClaims {
readonly sub: string
readonly email: Optional<string>
readonly name: Optional<string>
readonly tenantId: Optional<string>
readonly roles: Optional<string[]>
readonly permissions: Optional<string[]>
}

sub is the user identifier.

The remaining fields provide identity and authorization information that can be propagated into the application request context.

For example:

{
sub: user.id,
email: user.email,
name: user.name,
tenantId: user.tenantId,
roles: user.roles,
permissions: user.permissions,
}

Do not return an arbitrary application user object from authenticate().

Return the authentication claims expected by Xeno.JS.


After successful authentication, Xeno.JS maps AuthClaims to the request Identity.

The mapping is:

AuthClaims
│
├── sub → userId
├── email → email
├── name → name
├── tenantId → tenantId
├── roles → roles
└── permissions → permissions

The sub and tenantId values are parsed as GUID values during this mapping.

This means your custom authentication provider should return identifiers in the format expected by the application’s Xeno.JS identity model.


A complete composition root can look like this:

const app = new AppBuilder()
app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new MyTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})
app.addMiddlewares()
const container = await app.build()

Authentication middleware must be enabled with addMiddlewares().

Without the middleware module, the custom extractor is not used to authenticate incoming HTTP requests.


Custom authentication with a different credential format

Section titled “Custom authentication with a different credential format”

The custom extractor can support authentication formats other than:

Authorization: Bearer <token>

For example:

X-API-Token: <token>

The extractor can read that header:

export class ApiTokenExtractor
implements IServiceExtractor<Request['headers'], string | undefined>
{
extract(headers: Request['headers']): string | undefined {
return headers['x-api-token']
}
}

Register it:

app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new ApiTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})

The authentication service then receives the value returned by extract():

X-API-Token
│
▼
ApiTokenExtractor
│
▼
credential
│
▼
MyAuthService.authenticate()
│
▼
AuthClaims

If your provider issues JWTs, the custom authentication service can validate the JWT and map its trusted claims to AuthClaims.

For example, conceptually:

async authenticate(token: string): Promise<ResultType<AuthClaims>> {
const claims = await this.jwtVerifier.verify(token)
return Result.ok({
sub: claims.sub,
email: claims.email,
name: claims.name,
tenantId: claims.tenantId,
roles: claims.roles,
permissions: claims.permissions,
})
}

The provider-specific verification library is your responsibility.

Xeno.JS consumes the resulting AuthClaims; it does not require the authentication provider to use a particular token format.


Custom authentication with an external identity service

Section titled “Custom authentication with an external identity service”

The authentication service can also delegate validation to an external identity provider.

For example:

async authenticate(token: string): Promise<ResultType<AuthClaims>> {
const user = await this.identityProvider.validate(token)
return Result.ok({
sub: user.id,
email: user.email,
name: user.name,
tenantId: user.tenantId,
roles: user.roles,
permissions: user.permissions,
})
}

The important boundary remains:

credential
↓
provider-specific validation
↓
AuthClaims
↓
Xeno.JS Identity

The provider can be a JWT issuer, API gateway, OAuth/OIDC service, internal identity service, or another authentication system.


Custom authentication takes precedence over Supabase

Section titled “Custom authentication takes precedence over Supabase”

When customAuth is configured, Xeno.JS uses the custom authentication service instead of creating the built-in Supabase authentication service.

This means you should not configure custom authentication as an additional authentication provider expecting both services to participate.

Choose one authentication implementation:

Default
↓
Supabase authentication service

or:

Custom
↓
IExtendendAuthService implementation

The custom service becomes the authentication service used by the authentication flow.


Custom token extraction takes precedence over built-in extractors

Section titled “Custom token extraction takes precedence over built-in extractors”

Xeno.JS normally selects a token extractor based on the middleware configuration.

Without a custom extractor:

SSR enabled
→ Supabase SSR token extractor
SSR disabled
→ Bearer token extractor
+ configured authentication cookie

When customAuth.authHeaderExtractor is configured:

customAuth.authHeaderExtractor
↓
custom token extractor

The custom extractor is used instead of the built-in extractor.

This is important when using a custom provider with a non-standard header or credential format.


Do not put authentication validation in the extractor

Section titled “Do not put authentication validation in the extractor”

Avoid this design:

class MyTokenExtractor {
extract(headers: Request['headers']) {
// Parse token
// Verify token
// Load user
// Build identity
// ...
}
}

The extractor should retrieve the credential.

Prefer:

Extractor
↓
credential
↓
Authentication service
↓
validated AuthClaims

This keeps transport-specific logic separate from provider-specific authentication logic.


If the custom authentication service returns a failed ResultType from authenticate(), Xeno.JS treats the request authentication as failed.

The authentication middleware returns an unauthorized HTTP response instead of continuing to the application action.

Therefore, your authentication service should return an appropriate failed result when:

  • the token is invalid;
  • the token is expired;
  • the token cannot be verified;
  • the credential is revoked;
  • the authentication provider rejects the credential.

Do not convert invalid credentials into successful claims.


A request may reach the authentication service without an explicit token.

The authentication flow can then use:

getUser()

to resolve the current authenticated user.

If no authenticated user can be resolved, the request receives the guest identity rather than fabricated authentication claims.

This makes getUser() relevant for authentication mechanisms where the current user can be resolved without passing an explicit token to authenticate().


A custom authentication implementation should keep these responsibilities separate:

Component Responsibility
Token extractor Retrieve the credential from the HTTP request
Authentication service Validate the credential
AuthClaims Represent the authenticated identity
Claims mapper Convert claims into Xeno.JS Identity
Authorization Decide whether the identity may execute an application request

Authentication does not replace authorization.

For example, returning:

{
sub: user.id,
roles: ['admin'],
}

establishes identity and claims.

Whether that identity may execute a specific command is handled by the application’s authorization configuration.

See Authorization Overview and Authorization Policies.


customAuth is configured but Supabase is still being used

Section titled “customAuth is configured but Supabase is still being used”

Check that customAuth is configured inside addAuth():

app.addAuth((options) => {
options.customAuth = {
authHeaderExtractor: () => new MyTokenExtractor(),
authExtendedService: () => new MyAuthService(),
}
})

Do not configure the custom service elsewhere and expect AppBuilder to discover it automatically.


Make sure:

app.addMiddlewares()

is configured.

Also verify that the custom extractor is returned by the factory:

authHeaderExtractor: () => new MyTokenExtractor()

The property expects a factory that creates an IServiceExtractor; it is not the extractor instance itself.


Check the credential extraction first.

The flow is:

HTTP headers
↓
extract()
↓
credential
↓
authenticate(token)

If the extractor returns undefined, the authentication flow may resolve the current user through getUser() instead of calling authenticate() with a token.


Authentication succeeds but authorization fails

Section titled “Authentication succeeds but authorization fails”

Check the claims returned by your authentication service.

The following claims are propagated into the Xeno.JS identity:

{
sub,
email,
name,
tenantId,
roles,
permissions,
}

For example, if an authorization policy requires:

roles: ['admin']

your authenticated claims must contain the appropriate role.

Authentication establishes the identity; authorization evaluates whether that identity satisfies the configured policy.


The user ID or tenant ID is not available to authorization

Section titled “The user ID or tenant ID is not available to authorization”

Check sub and tenantId in the returned AuthClaims.

Xeno.JS maps:

sub → userId
tenantId → tenantId

Both values are parsed as GUIDs during identity mapping.

If the values are not valid GUIDs, they will not produce the expected Identity values.



Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section