How Logto Manages User Data: Architecture and Implementation Guide
Logto manages user data through a layered architecture that separates schema definitions, database queries, business logic libraries, and HTTP routes, ensuring type-safe operations with Zod validation and centralized user lifecycle management.
The logto-io/logto repository implements a robust user management system that organizes data handling into distinct layers. This architecture ensures strict type safety, prevents data leakage, and provides a clear separation between database operations, business logic, and API exposure.
Layered Architecture Overview
Logto organizes user data management into four primary layers:
- Schema Layer: Declares the shape of user records using Zod types and guards in
packages/schemas/src/types/user.ts - Database Layer: Low-level CRUD helpers generated at runtime for the
userstable and related entities - Library Layer: Business logic façade in
packages/core/src/libraries/user.tsthat orchestrates creation, validation, password handling, role assignment, MFA, and SSO provisioning - Route Layer: REST endpoints in
packages/core/src/routes-me/user.tsandpackages/core/src/routes/user-assets.tsthat expose library functions to clients
Schema Definitions and Type Safety
User Schema Structure
The foundation of Logto's user data management resides in packages/schemas/src/types/user.ts, which defines the User type using Zod validation. The schema explicitly enumerates safe fields for public API exposure through the userInfoSelectFields array:
export const userInfoSelectFields = Object.freeze([
'id',
'username',
'primaryEmail',
'primaryPhone',
'name',
'avatar',
'customData',
'identities',
'lastSignInAt',
'createdAt',
'updatedAt',
'profile',
'applicationId',
'isSuspended',
] satisfies Array<keyof User>);
The userInfoGuard created from this list guarantees that every API response conforms to the declared shape. This prevents accidental leakage of internal columns like password hashes or encryption metadata.
Core Business Logic Library
The createUserLibrary Factory
All high-level user operations live in the user library instantiated by createUserLibrary(tenantId, queries) in packages/core/src/libraries/user.ts. The library receives a Queries object that abstracts the underlying PostgreSQL tables (users, roles, organizations, SSO identities).
Key functions include:
insertUser: Generates a short-id, inserts the row, assigns default and custom roles, and emits a developer event for admin-tenant creationscheckIdentifierCollision: Validates uniqueness of email, phone, username, and SSO identities before creation or updatesverifyUserPassword: Validates supplied passwords against stored hashes, automatically upgrading to Argon2i when neededsignOutUser: Revokes all token and session instances for the supplied user IDprovisionOrganizations: Automatically adds users to JIT-derived organizations based on email domain, SSO connector, or explicit organization lists after sign-up
The library also encapsulates MFA handling via addUserMfaVerification and SSO identity retrieval through findUserSsoIdentities.
HTTP Routes and API Exposure
The library layer connects to HTTP endpoints that serve the admin console and experience SPA:
GET /api/me/user: Returns the authenticated user profile usinguserInfoGuardto filter fields. Implemented inpackages/core/src/routes-me/user.tsGET /api/user-assets/:userId: Streams avatar or binary assets stored in theuser_assetstable. Implemented inpackages/core/src/routes/user-assets.ts
Both routes rely on the library's findUserById and updateUserById helpers, ensuring consistent validation and business rules across all entry points.
Data Flow Example: User Sign-Up
The following flow illustrates how user data moves through the architecture during creation:
- API Request: The admin console sends
POST /api/userswith aCreateUserpayload - Validation: The route handler calls
userLibrary.checkIdentifierCollisionto guarantee uniqueness of identifiers - Password Security:
encryptUserPasswordfrompackages/core/src/utils/user.tscreates an Argon2i hash, storingpasswordEncryptedandpasswordEncryptionMethod - Persistence:
userLibrary.insertUserwrites the new row, assigns roles, and returns the persistedUserobject - Organization Provisioning:
provisionOrganizationsexecutes, adding the user to auto-join organizations based on domain or connector rules - Response Filtering: The route filters results through
userInfoGuardbefore sending safe fields to the client
All steps remain type-checked and validated by Zod guards defined in the schema file, maintaining a stable contract for external callers.
Code Examples
Creating a User via the Library
import { createUserLibrary } from '#src/libraries/user.js';
import type { Queries } from '#src/tenants/Queries.js';
const userLib = createUserLibrary('my-tenant-id', queries);
await userLib.checkIdentifierCollision({
primaryEmail: 'alice@example.com',
username: 'alice',
});
const [newUser] = await userLib.insertUser({
username: 'alice',
primaryEmail: 'alice@example.com',
passwordEncrypted: '',
passwordEncryptionMethod: '',
name: 'Alice',
});
console.log('Created user id:', newUser.id);
Verifying Password During Authentication
import { createUserLibrary } from '#src/libraries/user.js';
import { queries } from '#src/tenants/queries.js';
const userLib = createUserLibrary('my-tenant-id', queries);
const user = await queries.users.findUserById('user-id');
try {
const verifiedUser = await userLib.verifyUserPassword(user, 'plainPassword');
console.log('Login successful for', verifiedUser.id);
} catch (err) {
console.error('Invalid credentials');
}
Retrieving Current User Profile
import fetch from 'node-fetch';
const response = await fetch('http://localhost:3001/api/me/user', {
headers: { Authorization: `Bearer ${accessToken}` },
});
const profile = await response.json(); // conforms to UserInfo
console.log(profile.username, profile.primaryEmail);
Summary
- Logto implements a four-layer architecture (schema, database, library, routes) to manage user data with strict separation of concerns
- Zod guards in
packages/schemas/src/types/user.tsenforce type safety and prevent sensitive field leakage throughuserInfoSelectFields - The
createUserLibraryfactory inpackages/core/src/libraries/user.tscentralizes all business logic including collision detection, password verification, and organization provisioning - HTTP routes in
packages/core/src/routes-me/user.tsexpose only filtered user data, whilepackages/core/src/routes/user-assets.tshandles binary content - All user creation flows enforce Argon2i password hashing and automatic JIT organization assignment before returning sanitized responses
Frequently Asked Questions
How does Logto prevent sensitive user data from leaking in API responses?
Logto uses the userInfoSelectFields array and userInfoGuard Zod schema in packages/schemas/src/types/user.ts to explicitly whitelist only public-safe fields. When routes prepare responses, they filter user objects through these guards, ensuring internal columns like passwordEncrypted and passwordEncryptionMethod never reach clients.
What happens when a user signs up with an email domain that matches an organization?
The provisionOrganizations function in packages/core/src/libraries/user.ts automatically evaluates the user's email domain, SSO connector, or explicit organization list immediately after insertUser completes. If matching JIT (Just-In-Time) provisioning rules exist, the function adds the user to the appropriate organizations before the API returns the created user object.
Where is user password hashing implemented in Logto?
Password hashing logic resides in packages/core/src/utils/user.ts through the encryptUserPassword function, which uses Argon2i by default. During verification, verifyUserPassword in packages/core/src/libraries/user.ts compares supplied passwords against stored hashes and automatically migrates older hash formats to Argon2i when detected.
Can I extend the user schema with custom fields?
Yes, the customData field in the User schema accepts JSON objects for application-specific extensions. The core schema remains stable, but customData provides a flexible storage mechanism without requiring database migrations. Access this data through the standard library functions in createUserLibrary or the /api/me/user endpoint.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →