Admin Operations
Build a complete admin panel for user management. By the end of this guide you will have these endpoints working:
User management:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/admin/signup | POST | Create a user with overrides (bypass verification, generate password) |
/auth/admin/signup-social | POST | Import a social user (migration from Cognito, Auth0, etc.) |
/auth/admin/users | GET | List users with filters and pagination |
/auth/admin/users/:sub | GET | Get a single user by UUID |
/auth/admin/users/:sub | DELETE | Delete a user (cascade cleanup) |
/auth/admin/users/:sub/disable | POST | Lock a user account |
/auth/admin/users/:sub/enable | POST | Unlock a user account |
Password management:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/admin/set-password | POST | Set a user's password directly |
/auth/admin/reset-password/initiate | POST | Send a password reset code via email or SMS |
/auth/admin/users/:sub/force-password-change | POST | Force password change on next login |
Session management:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/admin/users/:sub/sessions | GET | List all active sessions for a user |
/auth/admin/users/:sub/logout-all | POST | Sign out a user from all devices |
MFA management:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/admin/users/:sub/mfa/status | GET | Check a user's MFA enrollment |
/auth/admin/users/:sub/mfa/devices | GET | List a user's MFA devices |
/auth/admin/mfa/devices/:deviceId | DELETE | Remove an MFA device |
/auth/admin/users/:sub/mfa/devices/:deviceId/preferred | POST | Set preferred MFA device |
/auth/admin/mfa/exemption | POST | Grant or revoke MFA exemption |
Audit:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/admin/audit/history | GET | Get authentication audit history for a user |
Admin routes are fully implemented in the nauth example apps — see the Express and Fastify examples for admin routes, and the Angular example for admin.component.ts.
nauth-toolkit authenticates but ships no role model, so it cannot decide who is an admin.
requireAuth() only proves the caller has a valid JWT — on its own, that makes every endpoint
below reachable by any signed-in user.
Supply an IAuthorizationProvider. It is enforced inside the
services, so it protects a controller you write and a background script just as it protects the
shipped routes. Mounting the admin route group without one is refused at startup.
Prerequisites
- Basic Auth Flows are working (signup, login, challenge endpoints)
- An authorization provider is configured
Exposing the admin API: pick one approach
There are two ways to get the admin endpoints onto your app. Choose one — you do not need both.
| Approach | Use it when |
|---|---|
| Option A: Mount the shipped routes | Default. The endpoints ship with the toolkit — mount them and they are live. |
| Option B: Write the routes yourself | You need custom paths, extra guards, or a different response shape. |
Both call the same services, so every operation documented in the rest of this guide behaves identically either way.
Option A: Mount the shipped routes
The admin endpoints ship with the toolkit. Mount them on their own prefix so the surface can be firewalled off separately from your public auth routes.
- NestJS
- Express
- Fastify
routes: [
{ prefix: 'auth' },
{ prefix: 'admin', groups: ['admin'] },
],
AuthModule.forRoot({ ...authConfig, authorization: RoleAuthorizer })
const adminRouter = express.Router();
registerNAuthExpressRoutes(adminRouter, nauth, { groups: ['admin'] });
app.use('/admin', adminRouter);
await fastify.register(
async (scope) => registerNAuthFastifyRoutes(scope, nauth, { groups: ['admin'] }),
{ prefix: '/admin' },
);
Route Groups lists every route the admin group
contains. Drop individual endpoints you do not want exposed:
{ prefix: 'admin', groups: ['admin'], exclude: ['adminSetPassword', 'adminDeleteUser'] }
The shipped admin bundle mounts on its own prefix, so its paths are /admin/users/:sub. The
hand-written examples in Option B nest under /auth/admin/*, matching examples/demo-nestjs.
Both work — the shipped bundle keeps admin separable at the network layer. See
Shipped Routes for the full table.
API keys cannot reach the shipped admin routes at all: every one sets apiKey: 'deny', which the guard enforces before the handler runs. The viaApiKey flag on the authorization context is for admin routes you write yourself and mark allowApiKey.
Option B: Write the routes yourself
Skip this section if you mounted the shipped routes in Option A.
The admin API uses two services from nauth-toolkit:
adminAuthService— user CRUD, passwords, sessionsmfaService— MFA status, devices, exemptions
Both are available on the nauth instance (Express/Fastify) or injectable via the NestJS module.
User management routes
- NestJS
- Express
- Fastify
import {
Controller, Get, Post, Delete, Body, Param, Query,
UseGuards, HttpCode, HttpStatus,
} from '@nestjs/common';
import { AdminAuthService, AuthGuard } from '@nauth-toolkit/nestjs';
// WARNING: AuthGuard only verifies the JWT is valid (authentication).
// You MUST add your own authorization guard to restrict access to admins.
// Example: @UseGuards(AuthGuard, YourAdminGuard)
@UseGuards(AuthGuard)
@Controller('auth/admin')
export class AdminController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
async createUser(@Body() dto: any) {
return await this.adminAuthService.signup(dto);
}
@Post('signup-social')
@HttpCode(HttpStatus.CREATED)
async importSocialUser(@Body() dto: any) {
return await this.adminAuthService.signupSocial(dto);
}
@Get('users')
async listUsers(@Query() query: any) {
return await this.adminAuthService.getUsers(query);
}
@Get('users/:sub')
async getUser(@Param('sub') sub: string) {
return await this.adminAuthService.getUserById({ sub });
}
@Delete('users/:sub')
@HttpCode(HttpStatus.OK)
async deleteUser(@Param('sub') sub: string) {
return await this.adminAuthService.deleteUser({ sub });
}
@Post('users/:sub/disable')
@HttpCode(HttpStatus.OK)
async disableUser(@Param('sub') sub: string, @Body() body: any) {
return await this.adminAuthService.disableUser({ sub, reason: body?.reason });
}
@Post('users/:sub/enable')
@HttpCode(HttpStatus.OK)
async enableUser(@Param('sub') sub: string) {
return await this.adminAuthService.enableUser({ sub });
}
@Post('set-password')
@HttpCode(HttpStatus.OK)
async setPassword(@Body() dto: any) {
return await this.adminAuthService.setPassword(dto);
}
@Post('reset-password/initiate')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() dto: any) {
return await this.adminAuthService.resetPassword(dto);
}
@Post('users/:sub/force-password-change')
@HttpCode(HttpStatus.OK)
async forcePasswordChange(@Param('sub') sub: string) {
return await this.adminAuthService.setMustChangePassword({ sub });
}
@Get('users/:sub/sessions')
async getUserSessions(@Param('sub') sub: string) {
return await this.adminAuthService.getUserSessions({ sub });
}
@Post('users/:sub/logout-all')
@HttpCode(HttpStatus.OK)
async logoutAll(@Param('sub') sub: string, @Body() body: any) {
return await this.adminAuthService.logoutAll({ sub, forgetDevices: body?.forgetDevices });
}
}
Register the controller in your auth module.
import { Router, Request, Response, NextFunction } from 'express';
export function createAdminRoutes(nauth, adminAuthService): Router {
const router = Router();
// WARNING: nauth.helpers.requireAuth() only verifies the JWT is valid (authentication).
// You MUST add your own authorization middleware to restrict access to admins.
router.post('/signup', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.status(201).json(await adminAuthService.signup(req.body)); } catch (err) { next(err); }
});
router.post('/signup-social', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.status(201).json(await adminAuthService.signupSocial(req.body)); } catch (err) { next(err); }
});
router.get('/users', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.getUsers(req.query)); } catch (err) { next(err); }
});
router.get('/users/:sub', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.getUserById(req.params as any)); } catch (err) { next(err); }
});
router.delete('/users/:sub', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.deleteUser(req.params as any)); } catch (err) { next(err); }
});
router.post('/users/:sub/disable', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.disableUser({ ...req.params, reason: req.body?.reason } as any)); } catch (err) { next(err); }
});
router.post('/users/:sub/enable', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.enableUser(req.params as any)); } catch (err) { next(err); }
});
router.post('/set-password', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.setPassword(req.body)); } catch (err) { next(err); }
});
router.post('/reset-password/initiate', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.resetPassword(req.body)); } catch (err) { next(err); }
});
router.post('/users/:sub/force-password-change', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.setMustChangePassword(req.params as any)); } catch (err) { next(err); }
});
router.get('/users/:sub/sessions', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.getUserSessions(req.params as any)); } catch (err) { next(err); }
});
router.post('/users/:sub/logout-all', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await adminAuthService.logoutAll({ sub: req.params.sub, forgetDevices: req.body?.forgetDevices })); } catch (err) { next(err); }
});
return router;
}
// Mount: app.use('/auth/admin', createAdminRoutes(nauth, nauth.adminAuthService))
import { FastifyInstance } from 'fastify';
export async function registerAdminRoutes(fastify: FastifyInstance, nauth, adminAuthService): Promise<void> {
// WARNING: nauth.helpers.requireAuth() only verifies the JWT is valid (authentication).
// You MUST add your own authorization hook to restrict access to admins.
fastify.post('/auth/admin/signup', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.status(201).json(await adminAuthService.signup(req.body as any));
}) as any
);
fastify.post('/auth/admin/signup-social', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.status(201).json(await adminAuthService.signupSocial(req.body as any));
}) as any
);
fastify.get('/auth/admin/users', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.getUsers(req.query as any));
}) as any
);
fastify.get('/auth/admin/users/:sub', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.getUserById(req.params as any));
}) as any
);
fastify.delete('/auth/admin/users/:sub', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.deleteUser(req.params as any));
}) as any
);
fastify.post('/auth/admin/users/:sub/disable', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.disableUser({ ...req.params, reason: (req.body as any)?.reason } as any));
}) as any
);
fastify.post('/auth/admin/users/:sub/enable', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.enableUser(req.params as any));
}) as any
);
fastify.post('/auth/admin/set-password', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.setPassword(req.body as any));
}) as any
);
fastify.post('/auth/admin/reset-password/initiate', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.resetPassword(req.body as any));
}) as any
);
fastify.post('/auth/admin/users/:sub/force-password-change', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.setMustChangePassword(req.params as any));
}) as any
);
fastify.get('/auth/admin/users/:sub/sessions', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.getUserSessions(req.params as any));
}) as any
);
fastify.post('/auth/admin/users/:sub/logout-all', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await adminAuthService.logoutAll({ sub: req.params.sub, forgetDevices: (req.body as any)?.forgetDevices } as any));
}) as any
);
}
Admin MFA routes
- NestJS
- Express
- Fastify
import {
Controller, Get, Post, Delete, Body, Param,
UseGuards, HttpCode, HttpStatus, Inject,
} from '@nestjs/common';
import { AuthGuard, MFAService } from '@nauth-toolkit/nestjs';
// WARNING: Add your own authorization guard alongside AuthGuard.
@UseGuards(AuthGuard)
@Controller('auth/admin')
export class AdminMfaController {
constructor(
@Inject(MFAService)
private readonly mfaService: MFAService,
) {}
@Get('users/:sub/mfa/status')
async getMfaStatus(@Param('sub') sub: string) {
return await this.mfaService.adminGetMfaStatus({ sub });
}
@Get('users/:sub/mfa/devices')
async getMfaDevices(@Param('sub') sub: string) {
return await this.mfaService.adminGetUserDevices({ sub });
}
@Delete('mfa/devices/:deviceId')
@HttpCode(HttpStatus.OK)
async removeDevice(@Param('deviceId') deviceId: string) {
return await this.mfaService.adminRemoveDevice({ deviceId: Number(deviceId) });
}
@Post('users/:sub/mfa/devices/:deviceId/preferred')
@HttpCode(HttpStatus.OK)
async setPreferredDevice(@Param('sub') sub: string, @Param('deviceId') deviceId: string) {
return await this.mfaService.adminSetPreferredDevice({ sub, deviceId: Number(deviceId) });
}
@Post('mfa/exemption')
@HttpCode(HttpStatus.OK)
async setMfaExemption(@Body() dto: any) {
return await this.mfaService.setMFAExemption(dto);
}
}
Register the controller in your auth module.
import { Router, Request, Response, NextFunction } from 'express';
export function createAdminMfaRoutes(nauth, mfaService): Router {
const router = Router();
// WARNING: Add your own authorization middleware alongside requireAuth().
router.get('/users/:sub/mfa/status', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await mfaService.adminGetMfaStatus(req.params as any)); } catch (err) { next(err); }
});
router.get('/users/:sub/mfa/devices', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await mfaService.adminGetUserDevices(req.params as any)); } catch (err) { next(err); }
});
router.delete('/mfa/devices/:deviceId', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await mfaService.adminRemoveDevice(req.params as any)); } catch (err) { next(err); }
});
router.post('/users/:sub/mfa/devices/:deviceId/preferred', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await mfaService.adminSetPreferredDevice(req.params as any)); } catch (err) { next(err); }
});
router.post('/mfa/exemption', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try {
const dto = { ...req.body };
if (!dto.grantedBy) dto.grantedBy = nauth.helpers.getCurrentUser()?.sub ?? null;
res.json(await mfaService.setMFAExemption(dto));
} catch (err) { next(err); }
});
return router;
}
// Mount: app.use('/auth/admin', createAdminMfaRoutes(nauth, mfaService))
import { FastifyInstance } from 'fastify';
export async function registerAdminMfaRoutes(fastify: FastifyInstance, nauth, mfaService): Promise<void> {
// WARNING: Add your own authorization hook alongside requireAuth().
fastify.get('/auth/admin/users/:sub/mfa/status', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await mfaService.adminGetMfaStatus(req.params as any));
}) as any
);
fastify.get('/auth/admin/users/:sub/mfa/devices', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await mfaService.adminGetUserDevices(req.params as any));
}) as any
);
fastify.delete('/auth/admin/mfa/devices/:deviceId', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await mfaService.adminRemoveDevice(req.params as any));
}) as any
);
fastify.post('/auth/admin/users/:sub/mfa/devices/:deviceId/preferred', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await mfaService.adminSetPreferredDevice(req.params as any));
}) as any
);
fastify.post('/auth/admin/mfa/exemption', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
const dto = { ...(req.body as object), grantedBy: (req.body as any).grantedBy ?? nauth.helpers.getCurrentUser()?.sub ?? null };
res.json(await mfaService.setMFAExemption(dto as any));
}) as any
);
}
Admin audit route
- NestJS
- Express
- Fastify
// Add to your AdminController:
@Get('audit/history')
async getAuditHistory(@Query() query: any) {
return await this.auditService.getUserAuthHistory(query);
}
Inject AuthAuditService (or access it via the nauth instance) in the constructor.
// Add to your admin routes:
router.get('/audit/history', nauth.helpers.requireAuth(), async (req: Request, res: Response, next: NextFunction) => {
try { res.json(await nauth.auditService!.getUserAuthHistory(req.query as any)); } catch (err) { next(err); }
});
// Add to your admin routes:
fastify.get('/auth/admin/audit/history', { preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await nauth.auditService!.getUserAuthHistory(req.query as any));
}) as any
);
Step 1: User Management
Create a user
POST /auth/admin/signup
Request body:
{
"email": "john@example.com",
"password": "SecurePass123!",
"firstName": "John",
"lastName": "Doe",
"isEmailVerified": true,
"mustChangePassword": false
}
Response:
{
"user": {
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"isEmailVerified": true,
"isPhoneVerified": false,
"isActive": true,
"isLocked": false,
"mfaEnabled": false,
"createdAt": "2025-01-15T10:30:00.000Z"
}
}
Create with auto-generated password
{
"email": "john@example.com",
"generatePassword": true,
"isEmailVerified": true,
"mustChangePassword": true
}
Response — includes the one-time generated password:
{
"user": { "sub": "...", "email": "john@example.com" },
"generatedPassword": "Kx9#mP2$vN7@qR4!"
}
The generated password is returned once and never stored in plain text. Deliver it securely to the user.
When mustChangePassword: true, the user's next login returns a FORCE_CHANGE_PASSWORD challenge — they must set a new password before receiving tokens. See Basic Auth Flows > Other challenge types for challenge handling.
All request fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
email | string | Yes | — | User email address |
password | string | Conditional | — | Required unless generatePassword: true |
username | string | No | — | Unique username (3-50 chars) |
firstName | string | No | — | First name |
lastName | string | No | — | Last name |
phone | string | No | — | Phone in E.164 format (+14155552671) |
metadata | object | No | — | Custom fields |
isEmailVerified | boolean | No | false | Skip email verification |
isPhoneVerified | boolean | No | false | Skip phone verification |
mustChangePassword | boolean | No | false | Force password change on next login |
generatePassword | boolean | No | false | Auto-generate a secure password |
Import a social user
Use this to migrate users from Cognito, Auth0, Firebase, or any OAuth provider.
POST /auth/admin/signup-social
Request body:
{
"email": "jane@example.com",
"provider": "google",
"providerId": "google_12345",
"providerEmail": "jane@gmail.com",
"firstName": "Jane",
"lastName": "Smith"
}
Response:
{
"user": {
"sub": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Smith",
"isEmailVerified": true,
"hasSocialAuth": true,
"socialProviders": ["google"]
},
"socialAccount": {
"provider": "google",
"providerId": "google_12345",
"providerEmail": "jane@gmail.com"
}
}
For hybrid accounts (social + password), include the password field. The user can then log in with either method.
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User email (auto-verified for social imports) |
provider | string | Yes | 'google' | 'apple' | 'facebook' |
providerId | string | Yes | Provider's unique user ID |
providerEmail | string | No | Email from the provider (may differ from user email) |
password | string | No | For hybrid social + password accounts |
socialMetadata | object | No | Raw OAuth profile data (stored for audit) |
firstName, lastName, username, phone, metadata | — | No | Same as admin signup |
isPhoneVerified | boolean | No | Skip phone verification |
mustChangePassword | boolean | No | Only relevant if password is provided |
List users
GET /auth/admin/users?page=1&limit=20&isEmailVerified=true&sortBy=createdAt&sortOrder=DESC
Response:
{
"users": [
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"isEmailVerified": true,
"isPhoneVerified": false,
"isActive": true,
"isLocked": false,
"mfaEnabled": true,
"hasSocialAuth": false,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-02-01T14:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"totalPages": 8
}
}
Filter parameters
| Parameter | Type | Description |
|---|---|---|
page | number | Page number (1-indexed, default: 1) |
limit | number | Records per page (default: 10, max: 100) |
email | string | Partial match on email |
phone | string | Partial match on phone |
isEmailVerified | boolean | Filter by email verification status |
isPhoneVerified | boolean | Filter by phone verification status |
hasSocialAuth | boolean | Filter by social auth presence |
isLocked | boolean | Filter by account lock status |
mfaEnabled | boolean | Filter by MFA enrollment |
createdAt[operator] | string | Date comparison: gt, gte, lt, lte, eq |
createdAt[value] | string | ISO 8601 date |
updatedAt[operator] | string | Same operators as createdAt |
updatedAt[value] | string | ISO 8601 date |
sortBy | string | 'email' | 'createdAt' | 'updatedAt' | 'username' | 'phone' |
sortOrder | string | 'ASC' | 'DESC' (default: 'DESC') |
Get a single user
GET /auth/admin/users/:sub
Returns the same user object as listed above.
Delete a user
DELETE /auth/admin/users/:sub
Response — cascade cleanup counts:
{
"success": true,
"deletedUserId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"deletedRecords": {
"sessions": 3,
"verificationTokens": 1,
"mfaDevices": 2,
"trustedDevices": 1,
"socialAccounts": 1,
"loginAttempts": 12,
"challengeSessions": 0,
"auditLogs": 47
}
}
Disable a user
Locks the account permanently and revokes all active sessions immediately. The next login attempt fails with AUTH_ACCOUNT_LOCKED.
POST /auth/admin/users/:sub/disable
Request body (optional):
{
"reason": "Account compromised"
}
Response:
{
"success": true,
"user": {
"sub": "a1b2c3d4-...",
"isActive": true,
"isLocked": true
},
"revokedSessions": 3
}
The call writes isLocked, lockReason, lockedAt and lockedUntil. It does not change isActive, which stays true. See isActive vs isLocked.
Enable a user
Clears the lock set by disable and resets the stored failed-attempt counter on the user row.
POST /auth/admin/users/:sub/enable
Response:
{
"success": true,
"user": {
"sub": "a1b2c3d4-...",
"isActive": true,
"isLocked": false
}
}
isActive vs isLocked
Both fields appear on every user response and both block login, but only one of them has an API behind it.
isLocked | isActive | |
|---|---|---|
| Set by | disable only | true at signup; nothing sets it false |
| Cleared by | enable only | Nothing |
| Login error | AUTH_ACCOUNT_LOCKED | AUTH_ACCOUNT_INACTIVE |
| Filterable in list users | Yes | No |
isLocked is the one you manage, and disable always makes it permanent: it writes lockedUntil: null, which never expires and needs enable to clear.
The failed-login lockout is a separate mechanism and does not appear on the user row at all. It is keyed by IP address in the storage adapter, and a caller who trips it gets RATE_LIMIT_LOGIN, not AUTH_ACCOUNT_LOCKED. enable therefore does not release it; it expires on its own.
isActive is a legacy account-lifecycle flag. It is still enforced at login, but no route, service method or DTO in the toolkit sets it to false, so it only ever matters if you write to the column directly from your own code. For ordinary account suspension use disable.
Step 2: Password Management
Set password directly
Sets a new password without requiring the old one. Optionally revokes all sessions.
POST /auth/admin/set-password
Request body:
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"newPassword": "NewSecurePass123!",
"mustChangePassword": true,
"revokeSessions": true
}
Response:
{
"success": true,
"mustChangePassword": true,
"sessionsRevoked": 3
}
Initiate password reset
Sends a reset code via email or SMS. The user completes the reset via POST /auth/reset-password/confirm below — not /auth/forgot-password/confirm, which is the self-service flow and validates a different code.
POST /auth/admin/reset-password/initiate
Request body:
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"deliveryMethod": "email",
"baseUrl": "https://myapp.com/reset-password",
"revokeSessions": true,
"reason": "User requested via support ticket"
}
Response:
{
"success": true,
"destination": "j***@example.com",
"deliveryMedium": "email",
"expiresIn": 3600,
"sessionsRevoked": 3
}
| Field | Type | Default | Description |
|---|---|---|---|
sub | string | — | User UUID (required) |
deliveryMethod | string | 'email' | 'email' or 'sms' |
baseUrl | string | — | Base URL for building the reset link (optional) |
codeExpiresIn | number | 3600 | Code expiry in seconds (300–86400) |
revokeSessions | boolean | false | Revoke all sessions immediately |
reason | string | — | Audit trail reason (max 500 chars) |
When baseUrl is set, the link also carries an email query param (e.g. ?code=123456&email=user%40example.com) so the consumer app's reset page can prepopulate the identifier field instead of asking the user to retype it — the admin already has the address on hand, so this adds no exposure the self-service flow doesn't already have. The self-service forgot-password link (AuthService.forgotPassword) does not include email.
Confirm password reset
Public endpoint — the user calls this directly with the code from the email/SMS, no admin auth involved. It only accepts codes generated by Initiate password reset above; a self-service forgot-password code is rejected here, and vice versa.
POST /auth/reset-password/confirm
Request body:
{
"identifier": "user@example.com",
"code": "123456",
"newPassword": "NewSecurePass123!"
}
Response:
{
"success": true
}
| Field | Type | Description |
|---|---|---|
identifier | string | Email, username, or phone — matches the target user (required) |
code | string | 6–10 digit code from the reset email/SMS (required) |
newPassword | string | 8–128 chars (required) |
:::warning Wrong-endpoint failures look identical
Posting this code to /auth/forgot-password/confirm instead returns the same PASSWORD_RESET_CODE_INVALID error as a genuinely wrong code — there's no distinct error for "right code, wrong endpoint." If a frontend page handles both admin-sent and self-service reset links, pick the endpoint from something in the link itself — the admin link's email query param is one such marker (the self-service link never has one), or append your own to the admin baseUrl.
:::
Force password change
Flags the user to change their password on the next login. The next login returns a FORCE_CHANGE_PASSWORD challenge.
POST /auth/admin/users/:sub/force-password-change
Response:
{
"success": true
}
Step 3: Session Management
Get user sessions
GET /auth/admin/users/:sub/sessions
Response:
{
"sessions": [
{
"sessionId": "sess_abc123",
"deviceId": "dev_xyz789",
"deviceName": "Chrome on MacOS",
"deviceType": "desktop",
"platform": "macOS",
"browser": "Chrome 120",
"ipAddress": "203.0.113.42",
"ipCountry": "US",
"ipCity": "San Francisco",
"lastActivityAt": "2025-02-15T10:30:00.000Z",
"createdAt": "2025-02-01T08:00:00.000Z",
"expiresAt": "2025-03-01T08:00:00.000Z",
"isTrustedDevice": true,
"isCurrent": false,
"authMethod": "password",
"authProvider": null
}
]
}
Logout all sessions
Signs the user out from all devices. Optionally revokes trusted device status.
POST /auth/admin/users/:sub/logout-all
Request body (optional):
{
"forgetDevices": true
}
Response:
{
"revokedCount": 5
}
Step 4: Admin MFA Management
Get MFA status
GET /auth/admin/users/:sub/mfa/status
Response:
{
"enabled": true,
"required": false,
"configuredMethods": ["totp", "passkey"],
"availableMethods": ["email", "sms", "totp", "passkey"],
"preferredMethod": "totp"
}
List MFA devices
GET /auth/admin/users/:sub/mfa/devices
Response:
{
"devices": [
{
"id": 1,
"type": "totp",
"name": "Google Authenticator",
"isPreferred": true,
"isActive": true,
"createdAt": "2025-01-10T12:00:00.000Z"
},
{
"id": 2,
"type": "passkey",
"name": "MacBook Pro Touch ID",
"isPreferred": false,
"isActive": true,
"createdAt": "2025-01-15T09:00:00.000Z"
}
]
}
Remove an MFA device
DELETE /auth/admin/mfa/devices/:deviceId
Response:
{
"removedDeviceId": 2,
"message": "Device removed successfully"
}
Set preferred MFA device
POST /auth/admin/users/:sub/mfa/devices/:deviceId/preferred
Response:
{
"message": "Preferred device updated"
}
Grant or revoke MFA exemption
Exempt a user from MFA requirements (e.g., service accounts).
POST /auth/admin/mfa/exemption
Request body:
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"exempt": true,
"reason": "Service account — no human login",
"grantedBy": "admin-user-uuid"
}
Response:
{
"message": "MFA exemption granted"
}
Step 5: Frontend SDK
The frontend SDK provides client.admin.* methods that call the admin endpoints above. This works with any frontend framework.
Configure
Enable admin operations by adding the admin block to your client config:
import { NAuthClient } from '@nauth-toolkit/client';
const auth = new NAuthClient({
baseUrl: 'https://api.example.com',
authPathPrefix: '/auth',
tokenDelivery: 'cookies',
admin: {
pathPrefix: '/admin', // Matches your backend route prefix
},
});
The SDK builds admin URLs as: baseUrl + authPathPrefix + admin.pathPrefix + endpoint path. With the config above, getUsers() calls https://api.example.com/auth/admin/users.
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
pathPrefix | string | '/admin' | Prefix prepended to all admin endpoint paths |
endpoints | object | — | Override individual endpoint paths (see below) |
headers | object | — | Additional headers sent with every admin request |
Default endpoint paths
All paths are relative to authPathPrefix + admin.pathPrefix:
| Key | Default Path | Method |
|---|---|---|
signup | /signup | POST |
signupSocial | /signup-social | POST |
getUsers | /users | GET |
getUser | /users/:sub | GET |
deleteUser | /users/:sub | DELETE |
disableUser | /users/:sub/disable | POST |
enableUser | /users/:sub/enable | POST |
forcePasswordChange | /users/:sub/force-password-change | POST |
setPassword | /set-password | POST |
resetPasswordInitiate | /reset-password/initiate | POST |
getUserSessions | /users/:sub/sessions | GET |
logoutAll | /users/:sub/logout-all | POST |
getMfaStatus | /users/:sub/mfa/status | GET |
getMfaDevices | /users/:sub/mfa/devices | GET |
removeMfaDeviceById | /mfa/devices/:deviceId | DELETE |
setPreferredMfaDevice | /users/:sub/mfa/devices/:deviceId/preferred | POST |
setMfaExemption | /mfa/exemption | POST |
getAuditHistory | /audit/history | GET |
Override individual paths if your backend uses different URLs:
admin: {
pathPrefix: '/admin',
endpoints: {
getUsers: '/users/list', // Override just this one
},
},
User management
// Create a user
const { user, generatedPassword } = await auth.admin.createUser({
email: 'john@example.com',
generatePassword: true,
isEmailVerified: true,
mustChangePassword: true,
});
// Import a social user
const { user, socialAccount } = await auth.admin.importSocialUser({
email: 'jane@example.com',
provider: 'google',
providerId: 'google_12345',
providerEmail: 'jane@gmail.com',
});
// List users with filters
const { users, pagination } = await auth.admin.getUsers({
page: 1,
limit: 20,
isEmailVerified: true,
mfaEnabled: false,
sortBy: 'createdAt',
sortOrder: 'DESC',
});
// Date range filter
const { users } = await auth.admin.getUsers({
createdAt: { operator: 'gte', value: new Date('2025-01-01') },
});
// Get a single user
const user = await auth.admin.getUser('a1b2c3d4-...');
// Delete a user
const { deletedRecords } = await auth.admin.deleteUser('a1b2c3d4-...');
// Disable / enable
const { revokedSessions } = await auth.admin.disableUser('a1b2c3d4-...', 'Suspicious activity');
await auth.admin.enableUser('a1b2c3d4-...');
Password management
// Set password directly (temporary, and revokes sessions, by default)
await auth.admin.setPassword('a1b2c3d4-...', 'NewSecurePass123!');
// Set a permanent password and leave sessions alone
await auth.admin.setPassword('a1b2c3d4-...', 'NewSecurePass123!', {
mustChangePassword: false,
revokeSessions: false,
});
// Initiate password reset (sends email/SMS)
const { destination } = await auth.admin.initiatePasswordReset({
sub: 'a1b2c3d4-...',
deliveryMethod: 'email',
baseUrl: 'https://myapp.com/reset-password',
revokeSessions: true,
});
// Force password change on next login
await auth.admin.forcePasswordChange('a1b2c3d4-...');
The user completes the reset with client.resetPasswordWithCode() — a top-level method, not under admin.*, since the user calls it directly and it requires no admin auth:
// On the page the reset link/email points to:
await auth.resetPasswordWithCode('user@example.com', '123456', 'NewSecurePass123!');
resetPasswordWithCode() only completes an admin-initiated reset (it posts to /auth/reset-password/confirm). A self-service "forgot password" code — from auth.forgotPassword() — is completed with auth.confirmForgotPassword() instead; the two codes are not interchangeable. See Confirm password reset above.
Session management
// Get all sessions
const { sessions } = await auth.admin.getUserSessions('a1b2c3d4-...');
// Logout all sessions + revoke trusted devices
const { revokedCount } = await auth.admin.logoutAllSessions('a1b2c3d4-...', true);
MFA management
// Check MFA status
const status = await auth.admin.getMfaStatus('a1b2c3d4-...');
console.log(status.enabled, status.configuredMethods);
// List MFA devices
const { devices } = await auth.admin.getMfaDevices('a1b2c3d4-...');
// Remove a device
await auth.admin.removeMfaDeviceById(123);
// Set preferred device
await auth.admin.setPreferredMfaDevice('a1b2c3d4-...', 123);
// Grant MFA exemption
await auth.admin.setMfaExemption('a1b2c3d4-...', true, 'Service account');
// Revoke MFA exemption
await auth.admin.setMfaExemption('a1b2c3d4-...', false);
Audit history
const history = await auth.admin.getAuditHistory({
sub: 'a1b2c3d4-...',
page: 1,
limit: 50,
eventType: 'LOGIN_SUCCESS',
});
Error Codes
Common errors returned by admin endpoints:
| Error Code | Description | Resolution |
|---|---|---|
NOT_FOUND | User not found | Check the sub UUID |
EMAIL_EXISTS | Email already registered | Use a different email |
USERNAME_EXISTS | Username already taken | Use a different username |
PHONE_EXISTS | Phone already registered | Use a different phone number |
WEAK_PASSWORD | Password doesn't meet policy | Use a stronger password |
SOCIAL_ACCOUNT_EXISTS | Provider + providerId already linked | This social account is already imported |
VALIDATION_FAILED | Invalid request format | Check required fields and formats |
What's Next
- Basic Auth Flows — Core authentication (signup, login, challenges)
- MFA — Multi-factor authentication setup
- Audit Logs — Query user activity history and display login events
- Lifecycle Hooks — Hook into auth events for custom logic (e.g., send welcome email after admin signup)
- Challenge System — How
FORCE_CHANGE_PASSWORDand other challenges work