Authentication
SyncAD uses a layered authentication system: OTP for initial identity verification, JWT for API authorization, PIN for quick re-entry on mobile, and optional biometrics for frictionless login.
Auth Flow by Platform
Mobile Apps (Parents, Teachers, Driver)
┌─────────┐ Phone ┌──────────────┐ OTP verified ┌──────────────┐
│ User │──────────────►│ POST /auth │────────────────►│ Store JWT │
│ App │ │ /send-otp │ + Refresh Token│ + PIN │
└─────────┘ └──────────────┘ └──────────────┘
┌─────────┐ OTP/PIN ┌──────────────┐ JWT attached ┌──────────────┐
│ User │──────────────►│ POST /auth │────────────────►│ API Request │
│ App │ │ /verify-otp │ + schoolId │ Authorized │
└─────────┘ └──────────────┘ └──────────────┘
Web Admin
┌─────────┐ Email/Password ┌──────────────┐ JWT attached ┌──────────────┐
│ Admin │───────────────────►│ POST /auth │───────────────►│ Dashboard │
│ UI │ │ /login │ │ Authorized │
└─────────┘ └──────────────┘ └──────────────┘
OTP Flow
Step 1 — Request OTP
POST /{role}/user-auth/send-otp
Content-Type: application/json
{ "phone": "+919876543210" }
API generates a 6-digit OTP, stores hashed in Redis (TTL 5 minutes), and dispatches via SMS provider (AWS SNS).
Step 2 — Verify OTP
POST /{role}/user-auth/verify-otp
Content-Type: application/json
{ "phone": "+919876543210", "otp": "123456" }
On success, returns:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "uuid",
"phone": "+919876543210",
"role": "PARENT",
"schoolId": "school-uuid"
}
}
Step 3 — Set PIN (first time only)
POST /{role}/user-auth/set-pin
Authorization: Bearer <accessToken>
{ "pin": "1234" }
PIN is stored as a bcrypt hash. Subsequent logins can use PIN instead of re-sending OTP.
Step 4 — Quick Re-Entry with PIN
POST /{role}/user-auth/verify-pin
Content-Type: application/json
{ "phone": "+919876543210", "pin": "1234" }
Returns new access + refresh tokens without SMS.
JWT Structure
// Access Token (15 minute expiry)
{
"sub": "user-uuid",
"role": "TEACHER",
"schoolId": "school-uuid",
"iat": 1712000000,
"exp": 1712000900 // +15 min
}
// Refresh Token (7 day expiry)
{
"sub": "user-uuid",
"type": "refresh",
"iat": 1712000000,
"exp": 1712600000 // +7 days
}
Token Refresh
When the access token expires, clients call:
POST /auth/refresh
Content-Type: application/json
{ "refreshToken": "eyJhbGciOiJIUzI1NiIs..." }
Old refresh token is invalidated (rotation). New access + refresh tokens are issued.
RBAC — Role Module Permissions
// packages/db/src/schema.ts
export const RoleModulePermission = sqliteTable('Role_Module_Permission', {
id: text('id').primaryKey(),
roleId: text('roleId').notNull().references(() => Role.id),
moduleId: text('moduleId').notNull().references(() => Module.id),
canRead: integer('canRead', { mode: 'boolean' }).default(false),
canWrite: integer('canWrite', { mode: 'boolean' }).default(false),
canDelete: integer('canDelete', { mode: 'boolean' }).default(false),
});
export const Module = sqliteTable('Module', {
id: text('id').primaryKey(),
name: text('name').notNull(), // attendance, exam, fee, leave, ...
displayName: text('displayName').notNull(),
});
Role Types
| Role | Description |
|---|---|
SCHOOL_ADMIN | Full access to school admin UI |
SUPER_ADMIN | Cross-school, provisioning access |
TEACHER | Per-module read/write as assigned |
PARENT | Read-only for linked students |
DRIVER | Bus tracking module only |
Teachers — Module-Level Permissions
Teachers have fine-grained permissions per module:
// Example: check if teacher can mark attendance
const hasWritePermission = await roleRepo.hasPermission(user.roleId, 'attendance', 'write');
// Teacher's effective permission = role permission AND their class/division assignment
Biometrics (Parents & Teachers)
On supported devices, after initial PIN setup:
// Using local_auth package
final authenticated = await LocalAuthentication().authenticate(
localizedReason: 'Authenticate to access SyncAD',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
),
);
if (authenticated) {
// Use stored access token directly — no PIN/OTP needed
}
Biometrics unlock the stored JWT, not a separate auth mechanism.
PassportJS Strategies
// apps/api/src/modules/auth/strategies/
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.jwtSecret,
});
}
async validate(payload: JwtPayload) {
const user = await userService.findById(payload.sub);
if (!user) throw new UnauthorizedException();
return user;
}
}
Evaluation Mode (Teachers App)
When evaluationMode=true is set at the school level, all teacher modules except Exam Management become read-only. This is enforced at the interceptor level:
// EvaluationInterceptor in teachers app
if (response.statusCode == 403 &&
response.data['error'] == 'EVALUATION_MODE_READONLY') {
// Show read-only UI, disable write buttons
// Re-fetch permissions from AuthProvider
}
Evaluation Mode restricts teachers to read-only on most modules during exam periods. See the Teachers App docs for the full module impact table.