npm.io
5.0.3 • Published 4h agoCLI

sentri

Licence
ISC
Version
5.0.3
Deps
4
Size
632 kB
Vulns
0
Weekly
2.7K

sentri

npm version license build

Auth and authorization library for Node.js — Express, Fastify, Hono, Elysia, and Koa. Supports two modes:

  • Server mode — runs as a standalone auth server with its own database schema (Kysely), issues JWTs, and exposes auth endpoints including a public key endpoint for SSO.
  • Client mode — used by other apps to validate tokens issued by the auth server, without a database.

Subpath exports

Import Description
sentri Full package re-export (backward compat)
sentri/core Framework-agnostic types, SentriError, SentriLogger
sentri/express Express adapter — createAuthExpress, middleware, router
sentri/fastify Fastify adapter — createAuthFastify, preHandlers, plugin
sentri/hono Hono adapter — createAuthHono, middleware, router
sentri/elysia Elysia adapter — createAuthElysia, middleware, router
sentri/koa Koa adapter — createAuthKoa, middleware, router

Table of Contents


Installation

npm install sentri

kysely is bundled with sentri — no separate installation needed for Kysely. However, you must install the driver for your database of choice.

For PostgreSQL:

npm install pg

For other databases:

# MySQL
npm install mysql2

# SQLite
npm install better-sqlite3

Peer dependencies (install only what you use):

# Express
npm install express

# Fastify
npm install fastify @fastify/cookie

# Hono
npm install hono

# Elysia
npm install elysia

Quick Start

import express from "express";
import { createAuthExpress } from "sentri/express";

import { PostgresDialect } from "kysely";
import pg from "pg";

const { Pool } = pg;

const auth = createAuthExpress({
  mode: "server",
  validRoles: ["user", "admin"] as const,
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
  }),
});

const app = express();
app.use(express.json());
app.use(auth.idempotencyMiddleware());

await auth.migrate();
app.use("/auth", auth.router());

app.get("/me", auth.protect(), (req, res) => res.json(req.user));
app.use(auth.errorHandler());
app.listen(3000);

createAuthServer() generates an RSA-2048 key pair at startup and enables GET /keys automatically.

For detailed Express docs see src/adapters/express/README.md.

Fastify (server mode)
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import { createAuthFastify } from "sentri/fastify";

import { PostgresDialect } from "kysely";
import pg from "pg";

const { Pool } = pg;

const auth = createAuthFastify({
  mode: "server",
  validRoles: ["user", "admin"] as const,
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
  }),
});

await auth.migrate();

const app = Fastify();
await app.register(cookie);
await app.register(auth.plugin(), { prefix: "/auth" });
app.setErrorHandler(auth.errorHandler());

app.get("/me", { preHandler: auth.protect() }, async (request, reply) => {
  reply.send(request.user);
});

await app.listen({ port: 3000 });

For detailed Fastify docs see src/adapters/fastify/README.md.

Hono (server mode)
import { Hono } from "hono";
import { createAuthHono } from "sentri/hono";
import type { SentriHonoEnv } from "sentri/hono";

import { PostgresDialect } from "kysely";
import pg from "pg";

const { Pool } = pg;

const auth = createAuthHono({
  mode: "server",
  validRoles: ["user", "admin"] as const,
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
  }),
});

await auth.migrate();

const app = new Hono<SentriHonoEnv>();

app.route("/auth", auth.router());

app.get("/me", auth.protect(), (c) => c.json(c.get("user")));

app.onError(auth.errorHandler());

export default app;

createAuthHono generates an RSA-2048 key pair at startup and enables GET /keys automatically. Works with Node.js, Cloudflare Workers, Bun, and Deno — use client mode for edge runtimes that cannot reach a PostgreSQL database.

For detailed Hono docs see src/adapters/hono/README.md.

Elysia (server mode)
import { Elysia } from "elysia";
import { createAuthElysia } from "sentri/elysia";

import { PostgresDialect } from "kysely";
import pg from "pg";

const { Pool } = pg;

const auth = createAuthElysia({
  mode: "server",
  validRoles: ["user", "admin"] as const,
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
  }),
});

await auth.migrate();

const app = new Elysia()
  .onError(auth.errorHandler())
  .group("/auth", (app) => app.use(auth.router()))
  .use(auth.protect())
  .get("/me", ({ user }) => user);

app.listen(3000);

For detailed Elysia docs see src/adapters/elysia/README.md.

Koa (server mode)
import Koa from "koa";
import Router from "@koa/router";
import bodyParser from "koa-bodyparser";
import { createAuthKoa } from "sentri/koa";

const auth = createAuthKoa({
  mode: "server",
  validRoles: ["user", "admin"] as const,
  db: { connectionString: process.env.DATABASE_URL! },
});

await auth.migrate();

const app = new Koa();
app.use(bodyParser());
app.use(auth.errorHandler());

const rootRouter = new Router();
rootRouter.use("/auth", auth.router().routes(), auth.router().allowedMethods());

rootRouter.get("/me", auth.protect(), (ctx) => {
  ctx.body = ctx.state.user;
});

app.use(rootRouter.routes());
app.use(rootRouter.allowedMethods());

app.listen(3000);

For detailed Koa docs see src/adapters/koa/README.md.

Client mode (any framework, any server)
import { createAuthExpress } from "sentri/express";

const auth = createAuthExpress({
  mode: "client",
  keyUri: "https://auth.myapp.com/auth/keys",
});
Server Mode — Custom Dialect
import express from "express";
import { createAuth } from "sentri";
import { PostgresDialect } from "kysely"; // kysely is bundled, no install needed
import { Pool } from "pg"; // pg is bundled, no install needed

const auth = createAuth({
  mode: "server",
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL }),
  }),
  secret: process.env.JWT_PRIVATE_KEY!, // RSA private key PEM (RS256) or plain string (HS256)
  algorithm: "RS256",
  validRoles: ["user", "admin"] as const,
});

const app = express();
app.use(express.json());
await auth.migrate();
app.use("/auth", auth.router());
app.use(auth.errorHandler());
app.listen(3000);
Client Mode (Other Apps)
import express from "express";
import { createAuth } from "sentri";

const auth = createAuth({
  mode: "client",
  keyUri: "https://auth.myapp.com/auth/keys",
});

const app = express();
app.get("/products", auth.protect(), auth.authorize("admin"), handler);
app.get(
  "/orders",
  auth.protect(),
  auth.permit((req) => req.user!.id === req.params.userId),
  handler,
);

app.use(auth.errorHandler());

Server Mode

Server mode manages users, sessions, and tokens entirely within Sentri. The user provides a database dialect; Sentri handles the schema.

createAuthServer(options) — PostgreSQL shortcut
import { createAuthServer } from "sentri/express";

const auth = createAuthServer({
  validRoles: ["user", "admin"] as const,

  // Connection string
  db: { connectionString: process.env.DATABASE_URL!, max: 10 },

  // — or individual params —
  // db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },

  // Optional
  accessExpiresIn: "15m",
  refreshExpiresIn: "7d",
  saltRounds: 12,
  apiKey: process.env.REGISTER_API_KEY,
  redisUrl: process.env.REDIS_URL, // enables Redis-backed idempotency cache
  rateLimit: { maxLoginAttempts: 5, maxRegisterAttempts: 5, durationSeconds: 900 }, // default 5 attempts per 15 min
});
createAuth(config) — Full config
import { createAuth } from "sentri/express";

createAuth({
  mode: "server",
  dialect, // required — Kysely Dialect
  secret: process.env.JWT_SECRET!, // required — RSA private key PEM (RS256) or string (HS256)
  validRoles: ["user", "admin"] as const, // required

  algorithm: "RS256", // default: 'HS256' | also: 'HS384','HS512','RS384','RS512'
  accessExpiresIn: "15m", // default: '15m'
  refreshExpiresIn: "7d", // default: '7d'
  saltRounds: 12, // default: 12 (bcrypt rounds, 1031)
  apiKey: process.env.REGISTER_API_KEY, // restricts POST /register
  redisUrl: process.env.REDIS_URL, // Redis URL for idempotency cache
  // -- Rate Limiting (optional) -----------------------------------------------
  // rateLimit: { maxLoginAttempts: 5, maxRegisterAttempts: 5, durationSeconds: 900 }, // optional rate limiting config
  cookie: { secure: true }, // httpOnly refresh token cookie
  accessCookie: { secure: true }, // non-httpOnly access token cookie (SPA)
  hooks: { onLogin, onFailedLogin, onLogout },
  isTokenRevoked: async (sessionId) =>
    await redis.sismember("revoked", sessionId),
  router: {
    // override built-in service functions
    login,
    register,
    refresh,
    logout,
    logoutAll,
    assignRoles,
    bulkCreateIdentifiers,
    bulkUpdateIdentifiers,
    bulkDeleteIdentifiers,
    changePassword,
  },
});
Database Migration
// Call once at startup — uses IF NOT EXISTS, safe to repeat
await auth.migrate();

Creates three tables:

  • sentri_users — id, password_hash, roles (JSON), created_at
  • sentri_sessions — id, user_id, expires_at, created_at
  • sentri_identifiers — id, user_id, type, value (globally unique), created_at

Client Mode

Client mode has no database. It fetches the auth server's public key and validates JWTs stateless.

createAuth({
  mode: "client",
  keyUri: "https://auth.myapp.com/auth/keys", // required
  validRoles: ["admin", "user"], // optional — TypeScript type safety only
});

Available methods: protect(), authorize(), permit(), errorHandler().

The public key is fetched once and cached for 1 hour. Token validation is fully stateless.


SSO Flow

[User]  →  POST /auth/login          (Sentri Auth Server)
       ←   { accessToken, user }

[User]  →  GET /products             (App A — client mode)
           Authorization: Bearer <accessToken>
       ←   App A fetches public key from GET /auth/keys, verifies JWT
       ←   200 OK

For SSO, use algorithm: 'RS256' on the server (or createAuthServer() which defaults to RS256). This automatically adds GET /keys to the auth router, which returns the public key in JWKS format (RFC 7517).

GET /auth/keys → { keys: [{ kty, use, kid, n, e, ... }] }

Client apps point keyUri at this endpoint and receive the public key automatically.


Multi-Identifier

Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are globally unique regardless of type.

Login accepts any identifier value — Sentri searches all types automatically. No concept of "primary" exists; the JWT payload only contains { id, roles, sessionId }.

Registration

Provide at least one identifier.

POST /register
Content-Type: application/json

{
  "identifiers": [
    { "type": "email",    "value": "rizz@example.com" },
    { "type": "username", "value": "rizz" }
  ],
  "password": "secret123",
  "roles": ["user"]
}

Response:

{
  "error": false,
  "statusCode": 201,
  "message": "User registered successfully",
  "data": {
    "user": {
      "id": "uuid",
      "roles": ["user"],
      "identifiers": [
        { "id": "uuid-1", "type": "email", "value": "rizz@example.com" },
        { "id": "uuid-2", "type": "username", "value": "rizz" }
      ]
    }
  }
}
Login

Send any of the user's identifier values — Sentri searches all types automatically.

POST /login
Content-Type: application/json

{ "identifier": "rizz", "password": "secret123" }
Bulk Create Identifiers
POST /me/identifiers
Authorization: Bearer <token>
Content-Type: application/json

{
  "identifiers": [
    { "type": "phone", "value": "+628123456789" }
  ]
}
Bulk Update Identifiers
PUT /me/identifiers
Authorization: Bearer <token>
Content-Type: application/json

{
  "identifiers": [
    { "id": "uuid-2", "type": "username", "value": "newrizz" }
  ]
}
Bulk Delete Identifiers
DELETE /me/identifiers
Authorization: Bearer <token>
Content-Type: application/json

{ "ids": ["uuid-2"] }

At least one identifier must remain after deletion.

Programmatic API
const auth = createAuth({ mode: 'server', ... });

// Register with multiple identifiers
await auth.register({
  identifiers: [
    { type: 'email',    value: 'rizz@example.com' },
    { type: 'username', value: 'rizz' },
  ],
  password: 'secret123',
});

// Add identifiers after registration
await auth.bulkCreateIdentifiers(userId, [{ type: 'phone', value: '+628123456789' }]);

// Update identifiers
await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', value: 'newrizz' }]);

// Delete identifiers
await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);

Configuration

algorithm
Value Type Use case
'HS256' Symmetric (default) Single app, shared secret
'RS256' Asymmetric SSO — enables GET /keys

When using RS256, secret must be a valid RSA private key in PEM format. createAuthServer() generates the key pair automatically.

createAuth({
  mode: "server",
  cookie: { secure: true }, // httpOnly refresh token
  accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
});

After login, both cookies are set automatically. protect() reads the access token from the cookie when no Authorization header is present.


Endpoints

auth.router() mounts these endpoints:

Method Path Auth Description
POST /register Create a user (requires X-Api-Key when apiKey is set)
POST /login Authenticate, receive tokens
POST /refresh Rotate refresh token
POST /logout Invalidate current session
POST /logout-all Invalidate all sessions
GET /me Return authenticated user with all identifiers
POST /me/identifiers ✓ self Add identifiers in bulk
PUT /me/identifiers ✓ self Update identifiers in bulk
DELETE /me/identifiers ✓ self Delete identifiers in bulk
PATCH /me/password ✓ self Change password — revokes all sessions
POST /users/:userId/roles ✓ admin Assign roles to user
GET /keys Public key in JWKS format (RS256 only)

All responses use the envelope:

{ "error": false, "statusCode": 200, "message": "...", "data": { ... } }
Change Password
PATCH /me/password
Authorization: Bearer <token>
Content-Type: application/json

{ "currentPassword": "old-pass", "newPassword": "new-pass" }

Changing the password revokes all existing sessions. The user must log in again after this call.


Middleware

auth.protect()

Verifies the JWT and sets the user on the request context. In server mode, also performs silent token refresh when the access token expires.

Token is read from Authorization: Bearer <token> header or access_token cookie.

// Express
app.get("/dashboard", auth.protect(), (req, res) => res.json(req.user));
// req.user: { id, roles }

// Fastify
app.get(
  "/dashboard",
  { preHandler: auth.protect() },
  async (request) => request.user,
);

// Hono
app.get("/dashboard", auth.protect(), (c) => c.json(c.get("user")));
auth.authorize(...roles)

Role-based access — must follow protect().

// Express / Fastify preHandler / Hono — same API
app.delete("/posts/:id", auth.protect(), auth.authorize("admin"), handler);
auth.permit(check | options)

Resource-level permission — must follow protect(). The check function receives the request context object of each framework.

// Express
app.put(
  "/users/:id",
  auth.protect(),
  auth.permit((req) => req.user!.id === req.params.id),
  handler,
);

// Hono
app.put(
  "/users/:id",
  auth.protect(),
  auth.permit((c) => c.get("user")!.id === c.req.param("id")),
  handler,
);

// Role bypass + ownership check
auth.permit({
  roles: ["admin"],
  check: async (req) => {
    const post = await db.posts.findById(req.params.id);
    return post?.authorId === req.user!.id;
  },
});
auth.rateLimiter(options)

Applies rate limiting to any custom route using the unified rate limiter cache (in-memory or Redis).

app.post(
  "/send-email",
  auth.rateLimiter({ maxAttempts: 3, durationSeconds: 60, keyPrefix: "email" }),
  handler
);

Token Utilities

Available on ServerAuthClient only:

const auth = createAuth({ mode: 'server', ... });

// Sign
const accessToken  = auth.signAccessToken({ id, roles });
const refreshToken = auth.signRefreshToken(sessionId);

// Verify
const user          = auth.verifyAccessToken(accessToken);
const { sessionId } = auth.verifyRefreshToken(refreshToken);

// Password
const hash  = await auth.hashPassword('secret123');
const valid = await auth.verifyPassword('secret123', hash);

// Extract raw token from request
const token = auth.getCurrentAccessToken(req);

Error Handling

// Express — must be last
app.use("/auth", auth.router());
app.use("/api", apiRouter);
app.use(auth.errorHandler());

// Fastify
app.setErrorHandler(auth.errorHandler());

// Hono — mount via onError
app.route("/auth", auth.router());
app.onError(auth.errorHandler());
Code HTTP Meaning
INVALID_CREDENTIALS 401 Wrong identifier or password
USER_NOT_FOUND 404 User does not exist
USER_ALREADY_EXISTS 409 Duplicate identifier at registration
IDENTIFIER_NOT_FOUND 404 Referenced identifier ID does not exist or belongs to another user
IDENTIFIER_ALREADY_EXISTS 409 Identifier value already taken by another user
TOKEN_EXPIRED 401 JWT exp is in the past
TOKEN_INVALID 401 Bad signature or malformed JWT
UNAUTHORIZED 401 No valid token or session not found
FORBIDDEN 403 Authenticated but missing required role
INVALID_ROLE 400 Role not in validRoles
VALIDATION_ERROR 400 Missing or invalid input
RATE_LIMIT_EXCEEDED 429 Too many requests to an endpoint
CONFIGURATION_ERROR 500 Invalid createAuth config
Extending SentriError
import { SentriError } from "sentri";

class NotFoundError extends SentriError {
  constructor(resource: string) {
    super("NOT_FOUND", `${resource} not found`, 404);
  }
}

// Caught automatically by auth.errorHandler()
app.get("/items/:id", auth.protect(), async (req, res) => {
  const item = await db.items.findById(req.params.id);
  if (!item) throw new NotFoundError("Item");
  res.json(item);
});

Request Idempotency

Repeat requests with the same X-Idempotency-Key header receive the cached response immediately (2xx only). Useful for POST/PUT/PATCH endpoints where clients may retry on network failure.

const auth = createAuthServer({
  validRoles: ["user", "admin"] as const,
  db: { connectionString: process.env.DATABASE_URL! },
  redisUrl: process.env.REDIS_URL, // omit for in-memory cache
});

// Mount before your routes
app.use(auth.idempotencyMiddleware());

// Override TTL or methods
app.use(auth.idempotencyMiddleware({ ttl: 60_000 }));

When redisUrl is set in server config, the middleware automatically uses Redis — no extra config needed.

Standalone (without createAuthServer)
import { createIdempotencyMiddleware } from "sentri";

// In-memory (single process)
app.use(createIdempotencyMiddleware({ ttl: 300_000 }));

// Redis (multi-process)
app.use(createIdempotencyMiddleware({ redisUrl: "redis://localhost:6379" }));
Options
Option Default Description
ttl 300_000 Cache TTL in milliseconds
header 'X-Idempotency-Key' Header name to read the key from
methods ['POST','PUT','PATCH'] HTTP methods to apply idempotency to
maxSize 10_000 Max in-memory entries (ignored when redisUrl is set)
redisUrl Redis connection URL for multi-process cache

Logger

Sentri produces structured JSON log entries for every auth event. Logging is opt-in — when no logger is configured, Sentri is completely silent (zero overhead).

Activating the logger

Pass any object that implements { info, warn, error } via the logger field in your config.

pino (recommended for production):

import pino from "pino";

const auth = createAuth({
  mode: "server",
  // ...other config
  logger: pino(),
});

winston:

import winston from "winston";

const auth = createAuth({
  mode: "server",
  // ...other config
  logger: winston.createLogger({
    transports: [new winston.transports.Console()],
  }),
});

console (zero setup, good for development):

const auth = createAuth({
  mode: "server",
  // ...other config
  logger: console,
});

Works identically in client mode:

const auth = createAuth({
  mode: "client",
  keyUri: "https://auth.myapp.com/auth/keys",
  logger: pino(),
});
Customising the service name

By default every log entry contains "service": "sentri". Override it with loggerService:

const auth = createAuth({
  // ...
  logger: pino(),
  loggerService: "auth-service",
});
Request ID correlation

If you mount a request-ID middleware before Sentri, the requestId is automatically included in every log entry for that request:

import { randomUUID } from "crypto";

app.use((req, _res, next) => {
  req.requestId = randomUUID();
  next();
});

app.use("/auth", auth.router());

Sample pino output:

{"level":30,"time":1719484800000,"service":"sentri","event":"auth.login.success","userId":"usr_abc","duration_ms":42,"requestId":"d4e5f6"}
{"level":40,"time":1719484801000,"service":"sentri","event":"auth.authorize.denied","userId":"usr_abc","userRoles":["user"],"requiredRoles":["admin"],"requestId":"d4e5f7"}
Log events
Event Level Emitted by Key fields
auth.protect.success info protect() userId, mode
auth.protect.failure warn protect() errorCode, mode
auth.protect.token_revoked warn protect() userId, mode
auth.protect.auto_refresh info protect() userId, mode
auth.authorize.passed info authorize() userId, userRoles, requiredRoles
auth.authorize.denied warn authorize() userId, userRoles, requiredRoles
auth.authorize.unauthenticated warn authorize() requiredRoles
auth.permit.passed info permit() userId
auth.permit.denied warn permit() userId
auth.permit.role_bypass info permit() userId, bypassedByRole
auth.permit.unauthenticated warn permit()
auth.register.success info router userId, duration_ms
auth.register.failure warn router errorCode, duration_ms
auth.login.success info router userId, duration_ms
auth.login.failure warn router errorCode, duration_ms
auth.refresh.success info router userId, duration_ms
auth.refresh.failure warn router errorCode, duration_ms
auth.logout info router duration_ms
auth.logout_all info router userId, duration_ms
auth.password.changed info router userId, duration_ms
auth.password.change_failure warn router userId, errorCode, duration_ms
auth.roles.assigned info router targetUserId, roles, duration_ms
auth.roles.assign_failure warn router targetUserId, errorCode, duration_ms
auth.identifiers.created info router userId, count, duration_ms
auth.identifiers.updated info router userId, count, duration_ms
auth.identifiers.deleted info router userId, duration_ms

All entries include service (configurable via loggerService) and requestId when available.

SentriLogger interface
import type { SentriLogger } from "sentri";

const myLogger: SentriLogger = {
  info(data: Record<string, unknown>) {
    /* ... */
  },
  warn(data: Record<string, unknown>) {
    /* ... */
  },
  error(data: Record<string, unknown>) {
    /* ... */
  },
};

Migration Guide

5.0.0 Breaking Changes
What changed Action required
is_primary column removed from sentri_identifiers Drop and recreate tables — run auth.migrate() after
AuthUser no longer has identifier / identifierType Update any code reading req.user — shape is now { id, roles }
JWT payload no longer includes identifier / identifierType Any code decoding the token directly must drop these fields
PATCH /me/identifiers/primary endpoint removed No replacement — the concept of primary is gone
changePrimaryIdentifier() removed from ServerAuthClient Delete call sites
ChangePrimaryResult type removed Delete references from type imports
bulkDeleteIdentifiers guard changed Old: "cannot delete primary". New: "must keep at least one identifier"
4.0.0 Breaking Changes
What changed Action required
sentri_users no longer has an identifier column Drop and recreate tables — identities now live in sentri_identifiers
New sentri_identifiers table Created automatically by auth.migrate()
RegisterInput.identifierRegisterInput.identifiers (array) Update register calls to pass identifiers: [{ type, value }]
PATCH /me/identifier removed Use PUT /me/identifiers for updates
changeIdentifier() removed from ServerAuthClient Use bulkUpdateIdentifiers()
New error codes: IDENTIFIER_NOT_FOUND, IDENTIFIER_ALREADY_EXISTS Handle these in your error handlers as needed
3.0.0 Breaking Changes
What changed Action required
createAuth now requires mode: 'server' or mode: 'client' Add mode: 'server' to existing configs
adapter field removed — Sentri owns the schema Remove adapter, add dialect (Kysely Dialect)
algorithm now supports 'RS256' | 'RS384' | 'RS512' Optional — HS256 still default
AuthError renamed to SentriError Update imports: import { SentriError } from 'sentri'
AUTH_ERROR_STATUS renamed to SENTRI_ERROR_STATUS Update references
npx sentri generate removed Run await auth.migrate() at startup instead
Templates directory removed No longer needed

Keywords