A self-hosted authentication platform built on Node.js, TypeScript, Express, and MongoDB.
AuthFlow gives you everything Clerk or Auth0 does — organizations, projects, multi-tenant user management, JWT rotation, RBAC, avatar uploads — except the data stays on your servers. No vendor lock-in, no per-seat pricing surprises, no black box.
- Two Separate User Systems
- How Requests Flow
- Tech Stack
- Project Structure
- Core Concepts
- API Reference
- Authentication & Token Strategy
- Role-Based Access Control
- Project Policies
- Avatar & Profile System
- Frontend Integration
- Environment Variables
- Getting Started
This is probably the most important thing to understand before diving in. AuthFlow manages two completely distinct types of users:
| Internal Users | End Users | |
|---|---|---|
| Who they are | Your dev team, admins, project owners | Your app's actual customers |
| Auth endpoint | /api/v1/auth |
/api/v1/project/:projectId/end-user |
| Governed by | Platform-level roles | Per-project policies you define |
Your team manages everything through the admin API. Your customers authenticate through a project-scoped API that follows whatever rules you've configured for that project.
Client Request
│
▼
server.ts (Express + Helmet + CORS + Rate Limiting)
│
▼
/api/v1 (Central Router)
│
├── /auth → Internal user auth + profile
├── /organizations → Org management
│ └── /:orgId/projects → Project management
├── /projects/:projectId/policy → Auth policy per project
├── /projects/:projectId/password-policy
├── /sessions → Session control
└── /project/:projectId/end-user → End-user auth + profile
Every module follows a Controller → Service pattern. Controllers are thin — they read the request, call the service, set cookies, and return. All real logic lives in the service layer, which always returns a plain { status, body } object instead of throwing. This makes error handling consistent and testing straightforward.
| Layer | Technology |
|---|---|
| Runtime | Node.js 18+ |
| Language | TypeScript |
| Framework | Express.js |
| Database | MongoDB (Mongoose) |
| Auth | JWT — access + refresh token rotation |
| Password Hashing | bcrypt |
| File Uploads | multer (memory storage) |
| Image Processing | sharp (resize + JPEG conversion) |
| Object Storage | AWS S3 (@aws-sdk/client-s3) |
| Security | Helmet, CORS, express-rate-limit |
| Cookies | cookie-parser (httpOnly) |
| Logging | Custom structured logger |
src/
├── config/
│ ├── auth.config.ts # JWT secrets, expiry, bcrypt rounds
│ └── database.ts # MongoDB connection
│
├── middleware/
│ ├── auth.middleware.ts # JWT verification + RBAC roleAuthorize
│ ├── endUser.middleware.ts # resolveProjectContext (loads project + policies)
│ └── upload.middleware.ts # multer (5 MB limit) + sharp → 400×400 JPEG
│
├── models/
│ ├── enums.ts # Role, Status, AuthType, AuthMethod enums
│ ├── models.types.ts # TypeScript interfaces for all documents
│ └── schema/
│ ├── user.schema.ts
│ ├── org.schema.ts
│ ├── organizationMembership.schema.ts
│ ├── project.schema.ts
│ ├── projectMembership.schema.ts
│ ├── projectPolicy.schema.ts
│ ├── passwordPolicy.schema.ts
│ ├── session.schema.ts
│ └── endUser.schema.ts
│
├── modules/
│ ├── index.ts # Central router
│ ├── user/ # Internal user auth + profile
│ ├── org/ # Org CRUD + members
│ ├── project/ # Project CRUD + members
│ ├── projectPolicy/
│ ├── passwordPolicy/
│ └── session/
│
├── services/
│ └── endUsers/ # End-user auth + profile
│
├── types/
│ ├── auth.types.ts # JWTPayload, AuthResponse, IServiceResponse
│ └── express.types.ts # AuthRequest interface
│
└── utils/
├── jwt.utils.ts # Generate / verify tokens
├── password.utils.ts # Hash, compare, validate
├── password.utils.EndUser.ts # Validate against project policy
├── s3.utils.ts # Upload, stream, delete from S3
├── uinifiedSignupValidator.ts # End-user signup validation
├── user.utils.ts # RBAC helpers
├── errors.ts # AppError, ValidationError, NotFoundError...
└── logger.ts # Structured logger
The top-level container. When you create one, you're automatically assigned the owner role. Organizations are identified by a unique slug you pick at creation.
A Project lives inside an Organization and is where your end-users sign up and authenticate. Before any end-user can register, the project needs a Password Policy and a Project Policy in place. The person who creates the project automatically gets the manager role.
Two separate membership models keep things clean:
OrganizationMembership— links an internal user to an org with a role (owner,admin,member)ProjectMembership— links an internal user to a project with a role (manager,contributor,viewer)
Policies define the rules end-users must follow when signing up. Setup order matters:
1. Create a Password Policy → 2. Create a Project Policy → 3. End-users can now sign up
Note: You can't delete a Password Policy while a Project Policy still references it. Delete the Project Policy first.
End Users are entirely separate from internal users. They sign up through a project-scoped endpoint and are stored as both a User document (identity) and an EndUser document (project membership with role/status). All their auth rules are enforced by the project's policy at signup time.
| Method | Endpoint | Auth? | Description |
|---|---|---|---|
POST |
/signup |
No | Register a new internal user |
POST |
/login |
No | Login — tokens set in cookies |
GET |
/me |
Yes | Get the current authenticated user |
POST |
/refresh-token |
No | Rotate access + refresh token pair |
POST |
/logout |
Yes | Logout current device |
PATCH |
/change-password |
Yes | Change password (requires current password) |
GET |
/profile |
Yes | Full profile with streaming avatarUrl |
PATCH |
/profile |
Yes | Update fullName or phone |
PATCH |
/avatar |
Yes | Upload image (multipart/form-data, field: avatar) |
DELETE |
/avatar |
Yes | Remove avatar from S3 and database |
GET |
/avatar/:userId |
Yes | Stream avatar bytes directly to client |
| Method | Endpoint | Roles | Description |
|---|---|---|---|
POST |
/ |
Authenticated | Create organization |
GET |
/:orgId |
Authenticated | Get organization |
PATCH |
/:orgId |
admin, owner |
Update organization |
DELETE |
/:orgId |
owner |
Delete organization |
GET |
/:orgId/members |
member+ |
List all members |
POST |
/:orgId/members |
admin, owner |
Add a member |
GET |
/:orgId/members/:userId |
member+ |
Get a specific member |
PATCH |
/:orgId/members/:userId |
admin, owner |
Update member role/status |
DELETE |
/:orgId/members/:userId |
admin, owner |
Remove a member |
| Method | Endpoint | Roles | Description |
|---|---|---|---|
POST |
/ |
admin, owner (org) |
Create project |
GET |
/ |
admin, owner (org) |
List all projects in org |
GET |
/:projectId |
member+ (org) |
Get project |
PATCH |
/:projectId |
admin, owner (org) |
Update project |
DELETE |
/:projectId |
owner (org) |
Delete project |
POST |
/:projectId/members |
admin, owner (project) |
Add project member |
GET |
/:projectId/members |
member+ (project) |
List project members |
GET |
/:projectId/members/:userId |
member+ (project) |
Get a specific member |
PATCH |
/:projectId/members/:userId |
admin, owner (project) |
Update member |
DELETE |
/:projectId/members/:userId |
admin, owner (project) |
Remove member |
| Method | Endpoint | Roles | Description |
|---|---|---|---|
POST |
/ |
manager, contributor |
Create policy |
GET |
/ |
manager, contributor, viewer |
Get policy |
PATCH |
/ |
manager, contributor |
Update policy |
DELETE |
/ |
manager, contributor |
Delete policy |
| Method | Endpoint | Roles | Description |
|---|---|---|---|
POST |
/ |
manager, contributor |
Create policy |
GET |
/ |
manager, contributor, viewer |
Get policy |
PATCH |
/ |
manager, contributor |
Update policy |
DELETE |
/ |
manager, contributor |
Delete policy |
| Method | Endpoint | Description |
|---|---|---|
GET |
/ |
List all active sessions (raw refresh tokens hidden) |
DELETE |
/ |
Revoke all sessions — logout everywhere |
DELETE |
/:sessionId |
Revoke one specific session |
| Method | Endpoint | Auth? | Description |
|---|---|---|---|
POST |
/signup |
No | Sign up (validated against project policy) |
POST |
/login |
No | Login |
GET |
/logout |
Yes | Logout |
GET |
/profile |
Yes | Profile with role, status, and streaming avatarUrl |
PATCH |
/profile |
Yes | Update fullName or phone |
PATCH |
/avatar |
Yes | Upload avatar |
DELETE |
/avatar |
Yes | Remove avatar |
GET |
/avatar/:userId |
Yes | Stream avatar bytes |
AuthFlow uses two tokens, both stored in httpOnly cookies. That means they're invisible to JavaScript — no XSS attack can steal them.
| Token | Lifetime | Cookie Name |
|---|---|---|
| Access Token | 15 minutes | accessToken |
| Refresh Token | 7 days | refreshToken |
The refresh token is also saved to a Session document in MongoDB, which is what makes revocation work.
Every call to POST /refresh-token does this:
- Verifies the incoming refresh token (signature + expiry)
- Looks up the session in MongoDB — if it's been revoked, this fails
- Deletes the old session
- Issues a fresh access + refresh token pair
- Creates a new session record
- Sets both tokens in cookies
Each refresh token is single-use. Replaying a stolen token won't work because the session it belonged to no longer exists.
POST /auth/logout → revoke current device only
DELETE /sessions/:sessionId → revoke one specific device
DELETE /sessions → revoke all devices (force logout everywhere)
| Role | What they can do |
|---|---|
owner |
Full control — create, read, update, delete org; manage all members |
admin |
Read + update org; manage members (can't delete org or remove the last owner) |
member |
Read-only access to org info and member list |
| Role | What they can do |
|---|---|
manager |
Full project control — members, policies, everything |
contributor |
Can modify policies and contribute to project config |
viewer |
Read-only access to project and policies |
The roleAuthorize(roles, type) middleware:
- Reads
req.user(set by theauthenticatemiddleware before this runs) - Pulls
orgIdorprojectIdfrom the request params/body/query - Looks up the user's membership record
- Checks if their role is in the allowed list
- Returns
403 Forbiddenif it's not — no exceptions
| Field | Type | Default | Description |
|---|---|---|---|
minLength |
number | 6 |
Minimum password length (floor is 4) |
requireNumbers |
boolean | true |
Must contain at least one digit |
requireUppercase |
boolean | true |
Must contain an uppercase letter |
requireSpecialChars |
boolean | false |
Must contain a special character |
| Field | Type | Default | Description |
|---|---|---|---|
authRequired |
boolean | true |
Whether auth is enforced |
authType |
enum | password |
password | oauth | 2fa |
authMethods |
array | [] |
email | phone | google | github |
phoneRequired |
boolean | false |
Is phone number mandatory at signup? |
roles |
string[] | [] |
Allowed end-user roles (empty = no restriction) |
statuses |
string[] | [] |
Allowed end-user statuses (empty = no restriction) |
passwordPolicyId |
ObjectId | required | Reference to the project's password policy |
Avatar uploads go through a three-stage pipeline — and the raw S3 URL is never exposed to clients under any circumstances.
Client uploads multipart/form-data (field: "avatar")
│
▼ Stage 1 — multer
│ • Validates MIME type: jpeg, png, webp, gif only
│ • Rejects files larger than 5 MB
│ • Buffers entirely in memory — never hits disk
│
▼ Stage 2 — sharp
│ • Resizes to 400 × 400 px (cover crop, centered)
│ • Converts any format to JPEG (quality 85, progressive)
│ • Strips EXIF metadata for privacy
│
▼ Stage 3 — S3 upload
│ • Deletes old avatar first (no orphan objects)
│ • Uploads processed buffer via PutObjectCommand
│ • Stores only the S3 key in MongoDB (select: false)
│ • Returns a backend streaming URL to the client
▼
Response: { avatarUrl: "/api/v1/auth/avatar/<userId>" }
The avatarKey field on the User schema is marked select: false, so it's excluded from every Mongoose query unless explicitly requested. No controller or service ever returns it.
When the client hits the streaming endpoint, the server fetches the object from S3 using GetObjectCommand and pipes the response stream directly to the HTTP response. The browser gets image bytes — it never sees an S3 URL.
avatars/
├── users/
│ └── <userId>.jpg ← internal users
└── endusers/
└── <userId>.jpg ← end-users (your project's customers)
Each user gets one avatar slot. Uploading a new image automatically deletes the old key before writing the replacement.
{
"message": "User logged in successfully",
"user": {
"id": "664abc...",
"fullName": "Jane Doe",
"email": "jane@example.com",
"avatarUrl": "/api/v1/auth/avatar/664abc..."
}
}If no avatar has been uploaded yet, avatarUrl is null.
AuthFlow sets tokens in httpOnly cookies automatically. Your frontend doesn't need to read, store, or attach tokens to anything — the browser handles it. Just make sure every request includes credentials:
// fetch
fetch('/api/v1/auth/me', { credentials: 'include' });
// axios — set once globally
axios.defaults.withCredentials = true;Access tokens expire after 15 minutes. This wrapper automatically retries after a refresh:
async function apiFetch(url, options = {}) {
let res = await fetch(url, { ...options, credentials: 'include' });
if (res.status === 401) {
const refreshRes = await fetch('/api/v1/auth/refresh-token', {
method: 'POST',
credentials: 'include'
});
if (refreshRes.ok) {
res = await fetch(url, { ...options, credentials: 'include' });
} else {
window.location.href = '/login';
}
}
return res;
}avatarUrl is a backend endpoint, not a raw S3 URL — use it directly in an <img> tag. The browser's cookie jar attaches auth automatically.
function Avatar({ user }) {
if (!user.avatarUrl)
return <div className="placeholder">{user.fullName[0]}</div>;
return (
<img src={user.avatarUrl} alt={user.fullName} width={40} height={40} />
);
}async function uploadAvatar(file) {
const formData = new FormData();
formData.append('avatar', file); // field name must be "avatar"
const res = await fetch('/api/v1/auth/avatar', {
method: 'PATCH',
credentials: 'include',
body: formData
// Don't set Content-Type — the browser sets it with the boundary automatically
});
const data = await res.json();
return data.avatarUrl; // "/api/v1/auth/avatar/<userId>"
}For end-users, change the URL to /api/v1/project/:projectId/end-user/avatar.
Server-side file requirements:
| Rule | Value |
|---|---|
| Max file size | 5 MB |
| Accepted types | JPEG, PNG, WebP, GIF |
| Output format | JPEG, 400 × 400 px, quality 85 |
| EXIF data | Stripped automatically |
await fetch(`/api/v1/project/${projectId}/end-user/signup`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullName: 'Jane Doe',
email: 'jane@example.com',
password: 'SecurePass1!',
authMethod: 'email', // must match the project policy's authMethods
role: 'user', // must be in the project policy's roles (if configured)
status: 'active' // must be in the project policy's statuses (if configured)
})
});// 1. Create an org
const org = await apiFetch('/api/v1/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Acme Corp', slug: 'acme-corp' })
});
// 2. Create a project inside it
const project = await apiFetch(`/api/v1/organizations/${org.id}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'My App', description: 'Production app' })
});
// 3. Create a password policy for the project
await apiFetch(`/api/v1/projects/${project.id}/password-policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minLength: 8, requireNumbers: true, requireUppercase: true })
});
// 4. Create the project policy (password policy must exist first)
await apiFetch(`/api/v1/projects/${project.id}/policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
authType: 'password',
authMethods: ['email'],
authRequired: true,
phoneRequired: false,
roles: ['user', 'admin'],
statuses: ['active']
})
});
// End-user endpoints are now live at /api/v1/project/${project.id}/end-userAll endpoints return a consistent structure:
{
"message": "Human-readable status message",
"user": {},
"org": {},
"project": {}
}Errors look like this:
{
"message": "Error description",
"errors": ["Detailed error 1", "Detailed error 2"]
}Create a .env file in the project root:
# Server
PORT=5000
NODE_ENV=development
# MongoDB
MONGODB_URI=mongodb://localhost:27017/authflow
# CORS — must be your exact frontend URL in production
CORS_ORIGIN=http://localhost:3000
# JWT — use long random secrets in production (32+ characters)
JWT_ACCESS_SECRET=your-access-token-secret
JWT_REFRESH_SECRET=your-refresh-token-secret
# AWS S3 — required for avatar upload and streaming
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-iam-access-key
AWS_SECRET_ACCESS_KEY=your-iam-secret-key
AWS_S3_BUCKET=your-bucket-nameIn production, prefer IAM roles or instance profiles over hardcoded AWS credentials. Never commit
.envto version control.
The bucket must be private (no public access). The IAM user running the server only needs these three actions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::your-bucket-name/avatars/*"
}
]
}- Node.js 18+
- MongoDB (local or Atlas)
- AWS account with an S3 bucket and IAM credentials
# Clone and enter the project
git clone <repo-url>
cd authflow
# Install dependencies
npm install
npm install @aws-sdk/client-s3 multer sharp
npm install -D @types/multer
# Set up environment
cp .env.example .env
# Fill in your values in .env
# Start in development mode
npm run dev
# Build and start for production
npm run build
npm start- Sign up as an internal user →
POST /api/v1/auth/signup - Verify your account — there's no email flow yet, so set
isVerified: truemanually in MongoDB - Create an organization →
POST /api/v1/organizations - Create a project →
POST /api/v1/organizations/:orgId/projects - Create a password policy →
POST /api/v1/projects/:projectId/password-policy - Create a project policy →
POST /api/v1/projects/:projectId/policy - End-user endpoints are now live at
/api/v1/project/:projectId/end-user - Upload your avatar →
PATCH /api/v1/auth/avatarwithmultipart/form-data, fieldavatar