Skip to content

@mantlejs/auth

JWT authentication engine for Mantle JS. Provides token issuance, verification, a strategy runner, and hook utilities. Designed to be paired with a strategy package such as @mantlejs/auth-local.


Terminal window
npm install @mantlejs/auth jsonwebtoken

auth() is a Mantle plugin. It registers an AuthEngine on the application and mounts a built-in authentication service at POST /authentication. The engine is the single source of truth for JWT configuration and strategy dispatch — strategy packages (like auth-local) register themselves into it.

A strategy is an object with a name and an authenticate(data, params) method. The engine calls the matching strategy when POST /authentication arrives with a strategy field in the body. Strategy packages call engine.registerStrategy(strategy) during their own plugin registration.

authenticate('jwt') is a before hook that reads Authorization: Bearer <token> from the request headers, verifies the token, and writes the decoded payload to params.user.

authorizeAgent() is a before hook for capability-scoped agent tokens (see Agent tokens below) — opt-in per route, and never a substitute for authenticate('jwt').

sanitizeUser() is an after hook that strips sensitive fields (password, passwordHash) from service results before they leave the server.

Hooks registered with authenticate('jwt') automatically skip when the call has no provider (i.e. calls that originate within the server rather than from HTTP). This lets auth strategies query user services without tripping their own guards.


import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";
import { localStrategy, hashPassword } from "@mantlejs/auth-local";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(localStrategy());
app.use("/users", new UserService(new UserRepository(app)), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.service("users").hooks({
before: {
create: [hashPassword()],
find: [authenticate("jwt")],
get: [authenticate("jwt")],
patch: [authenticate("jwt")],
remove: [authenticate("jwt")],
},
after: {
all: [sanitizeUser()],
},
});
app.listen(3030);

Register a user

POST /users
Content-Type: application/json
{ "email": "alice@example.com", "password": "hunter2" }

Log in

POST /authentication
Content-Type: application/json
{ "strategy": "local", "email": "alice@example.com", "password": "hunter2" }
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 1, "email": "alice@example.com" }
}

Call a protected endpoint

GET /users
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Returns a MantlePlugin. Call via app.configure(auth(config)).

app.configure(
auth({
secret: process.env.JWT_SECRET!, // required — signing key
expiresIn: "7d", // optional — default "1d"
algorithms: ["HS256"], // optional — default ["HS256"]
issuer: "my-api", // optional
audience: "my-client", // optional
}),
);

Side effects:

  • Stores the AuthEngine at app.get("auth")
  • Registers POST /authentication via app.use("authentication", ...)
Field Type Default Description
secret string — JWT signing secret (required)
expiresIn string | number "1d" Access-token lifetime. Strings use ms format ("2h", "7d"). Numbers are seconds.
algorithms string[] ["HS256"] Verification algorithms
issuer string — Sets and verifies the iss claim
audience string | string[] — Sets and verifies the aud claim
refreshExpiresIn string | number "30d" Refresh-token lifetime
refreshTokenStore RefreshTokenStore in-memory Storage for outstanding refresh tokens. Multi-instance deployments (Cloud Run) must inject a shared store — the in-memory default cannot revoke tokens issued by another instance. Use redisRefreshTokenStore from @mantlejs/auth-redis.
agentTokenStore AgentTokenStore in-memory Storage for outstanding agent tokens, enabling revokeAgentToken() before JWT expiry. Same multi-instance caveat as refreshTokenStore.

A before hook factory. Returns a hook that authenticates the incoming request.

app.service("messages").hooks({
before: {
all: [authenticate("jwt")],
},
});

authenticate("jwt")

Reads Authorization: Bearer <token> from params.headers. On success writes the decoded JWT payload to params.user. On failure throws NotAuthenticated.

Silently skips (passes through) when params.provider is undefined — internal service calls are trusted.

Resolving the full user record — authenticate("jwt", { entity })

By default params.user is just the raw JWT payload ({ sub, iat, exp, ... }). Pass entity to resolve the actual user record instead:

app.service("messages").hooks({
before: {
all: [authenticate("jwt", { entity: "users" })],
},
});

With entity set, the hook calls app.service(entity).get(payload.sub) internally (no provider, so the target service’s own auth guards are bypassed for this lookup) and writes the result to params.user. The raw JWT payload moves to params.authPayload instead. If the subject no longer resolves to a user (e.g. deleted after the token was issued), the hook throws NotAuthenticated("User for this token no longer exists").

authenticate("custom")

For non-JWT strategies: delegates to engine.authenticate(strategyName, context.data, context.params) and writes the result to params.user. Skips for internal calls.


A before hook factory. Returns a hook that authorizes a capability-scoped agent token — see Agent tokens for issuance and revocation.

app.service("articles").hooks({
before: { all: [authorizeAgent()] },
});

Reads Authorization: Bearer <token> from params.headers, verifies it, and checks it’s an unrevoked type: "agent" token whose scope covers HookContext.path+HookContext.method (@mantlejs/mantle’s matchesCapabilityScope — the same deny-by-default matcher @mantlejs/mcp’s expose map uses). On success sets HookContext.agent = { id, scope, delegatingUserId }, additive to params.user. Throws Forbidden for an out-of-scope call, NotAuthenticated for anything else (missing/expired/malformed/non-agent/revoked token, or auth not configured).

Silently skips (passes through) when params.provider is undefined — internal service calls are trusted, same convention as authenticate("jwt").

Opt-in per route: authenticate("jwt") rejects a type: "agent" payload outright, so a route with no authorizeAgent() hook attached can never be reached with an agent token.


An after hook that removes sensitive fields from the service result. Works with single objects, arrays, and paginated results.

app.service("users").hooks({
after: {
all: [sanitizeUser()], // removes password, passwordHash, password_hash
get: [sanitizeUser(["secret", "apiKey"])], // custom field list
},
});

Default fields removed: "password", "passwordHash", "password_hash".


Accessed via app.get<AuthEngine>("auth"). Useful when writing custom strategy packages.

const engine = app.get<AuthEngine>("auth");
// Create a token
const token = engine.createJwt({ sub: String(user.id), role: "admin" });
// Verify a token
const payload = engine.verifyJwt(token); // throws if invalid
// Register a custom strategy
engine.registerStrategy({
name: "magic-link",
async authenticate(data, params) {
// verify magic link token, find user, issue JWT...
return { accessToken: engine.createJwt({ sub: userId }) };
},
});

The built-in authentication endpoint. Dispatches to a registered strategy based on the strategy field.

Request

{
"strategy": "local",
"email": "alice@example.com",
"password": "hunter2"
}

Response (201 Created)

{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 1, "email": "alice@example.com" }
}

Errors

Status Condition
400 Bad Request strategy field missing
400 Bad Request Strategy name not registered
401 Not Authenticated Strategy-specific failure (wrong password, user not found, etc.)

Every login (local or OAuth) returns an access + refresh token pair issued by engine.createTokenPair(sub). The refresh token carries { sub, type: "refresh", jti }, lives refreshExpiresIn (default 30 days), and its jti is recorded in the RefreshTokenStore.

Exchange it for a fresh pair via the built-in refresh strategy — same endpoint, no extra route:

POST /authentication
{ "strategy": "refresh", "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." }

Response (201 Created): { "accessToken": "...", "refreshToken": "..." } — no user field; a refresh proves possession of a token, not fresh credentials.

Refresh tokens rotate: each successful exchange consumes the submitted token and issues a new one. Replaying an already-consumed token is treated as theft — all outstanding refresh tokens for that subject are revoked and the request fails with 401 NotAuthenticated("Refresh token reuse detected"). Expired tokens, access tokens submitted as refresh tokens, and unknown signatures all fail with 401 NotAuthenticated("Invalid refresh token").

Custom strategies should issue tokens through engine.createTokenPair(sub, accessExtra?) rather than createJwt directly, so their refresh tokens participate in rotation and revocation.


Short-lived, capability-scoped tokens for AI-agent callers, delegated from a user rather than representing one. Distinct from user JWTs and refresh tokens — authenticate("jwt") rejects an agent token outright, and authorizeAgent() rejects anything that isn’t one.

Issuing a token — typically from your own route/service, after checking the delegating user is allowed to grant the requested scope:

const engine = app.get<AuthEngine>("auth");
const issued = await engine.issueAgentToken(
{ articles: ["find", "get"], comments: true }, // CapabilityScope: methods per path, or `true` for all
user.id, // delegatingUserId
{ expiresIn: "15m" }, // optional, default "15m"
);
// issued: { accessToken, id, expiresAt }

Authorizing agent calls — attach authorizeAgent() to any route an agent may call. It’s opt-in per route: a route with no authorizeAgent() hook can never be reached with an agent token, no matter its scope, because authenticate("jwt") refuses type: "agent" payloads.

app.service("articles").hooks({
before: { all: [authorizeAgent()] },
});

On success it sets HookContext.agent = { id, scope, delegatingUserId } — additive, params.user is untouched. On an out-of-scope path+method it throws Forbidden; on a missing, expired, malformed, non-agent, or revoked token it throws NotAuthenticated.

Revoking a token before its natural expiry:

await engine.revokeAgentToken(issued.id);

Revocation is tracked in the AgentTokenStore (default: in-memory). Multi-instance deployments must inject a shared store — same rationale as refreshTokenStore above.


import type {
AgentPrincipal,
AgentTokenOptions,
AgentTokenStore,
AuthConfig,
AuthEngine,
AuthResult,
AuthStrategy,
IssuedAgentToken,
JwtPayload,
RefreshTokenStore,
TokenPair,
} from "@mantlejs/auth";
Type Description
AuthConfig Options passed to auth()
AuthEngine The engine stored at app.get("auth")
AuthResult { accessToken: string; [key: string]: unknown } — returned by strategies
AuthStrategy Interface to implement for custom strategies
JwtPayload Decoded JWT payload shape
RefreshTokenStore add(jti, sub, exp) / consume(jti) / revokeAll(sub) — inject a shared implementation (e.g. @mantlejs/auth-redis) for multi-instance deployments
TokenPair { accessToken, refreshToken } — returned by engine.createTokenPair()
AgentPrincipal { id, scope: CapabilityScope, delegatingUserId } — an agent token’s decoded shape, and HookContext.agent’s type once authorizeAgent() accepts it
AgentTokenOptions { expiresIn? } — passed to engine.issueAgentToken()
AgentTokenStore add(id, exp) / isValid(id) / revoke(id) — inject a shared implementation for multi-instance deployments
IssuedAgentToken { accessToken, id, expiresAt } — returned by engine.issueAgentToken()

import type { MantlePlugin } from "@mantlejs/mantle";
import type { AuthEngine, AuthStrategy } from "@mantlejs/auth";
export function magicLinkStrategy(): MantlePlugin {
return (app) => {
const engine = app.get<AuthEngine>("auth");
if (!engine) throw new Error("@mantlejs/auth must be configured first");
const strategy: AuthStrategy = {
name: "magic-link",
async authenticate(data) {
// 1. Validate data.token against your store
// 2. Look up the user
// 3. Return the JWT
const accessToken = engine.createJwt({ sub: String(userId) });
return { accessToken, user };
},
};
engine.registerStrategy(strategy);
};
}

Register it alongside your other plugins:

app.configure(auth({ secret: process.env.JWT_SECRET! })).configure(magicLinkStrategy());

All errors extend MantleError and serialize to JSON automatically.

Error Code When thrown
NotAuthenticated 401 Missing/invalid/expired Bearer token
BadRequest 400 strategy field missing from POST /authentication
BadRequest 400 Named strategy not registered

Terminal window
npx nx build auth # compile
npx nx test auth # run tests
npx nx lint auth # lint

Build before publishing:

Terminal window
npx nx build auth

First publish (scoped packages require --access public):

Terminal window
cd packages/auth
npm publish --access public

Subsequent releases — bump version in packages/auth/package.json, then:

Terminal window
cd packages/auth
npm publish
Terminal window
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/auth
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/auth --registry http://localhost:4873