Authorization
Learn how to control what authenticated users can do — MongoDB built-in roles and application-level role-based access control (RBAC).
Authorization
Authentication answers "who are you?" Authorization answers a different question — what are you allowed to do?
A logged-in teacher and a logged-in student are both authenticated — MongoDB and your app both know who they are. But a teacher should be able to update grades for their own courses, while a student should only be able to view their own grades. That distinction is authorization.
Just like authentication, authorization also has two layers — MongoDB's built-in roles, and your application's own role-based access control.
MongoDB Built-in Roles
MongoDB has built-in roles that control what a database user can do at the database level. We touched on this briefly in the Authentication file when we created schoolAppUser with the readWrite role.
Common Built-in Roles
| Role | What it allows |
|---|---|
read | Read data only — no writes |
readWrite | Read and write data |
dbAdmin | Manage indexes, run validation, view stats — no data access changes |
userAdmin | Create and manage database users |
readWriteAnyDatabase | Read and write across all databases |
root | Full access to everything — superuser |
Example — Different Users for Different Purposes
use school
// Application user — full read/write for the app
db.createUser({
user: "schoolAppUser",
pwd: "appPassword",
roles: [{ role: "readWrite", db: "school" }]
})
// Reporting user — read-only, for analytics dashboards
db.createUser({
user: "reportingUser",
pwd: "reportPassword",
roles: [{ role: "read", db: "school" }]
})
// Backup user — read-only across the cluster
db.createUser({
user: "backupUser",
pwd: "backupPassword",
roles: [{ role: "readAnyDatabase", db: "admin" }]
})MongoDB roles control access at the database connection level — they apply to whoever holds those credentials. They do not know anything about your app's individual users (teachers, students). For that, you need application-level authorization — covered next.
Application-Level Authorization — Role-Based Access Control (RBAC)
In our school system, every user has a role field — admin, teacher, or student. RBAC means checking this role before allowing an action.
Defining Roles in the Schema
We already defined this in the Authentication file:
const userSchema = new mongoose.Schema({
name: String,
email: String,
password: String,
role: {
type: String,
enum: ["admin", "teacher", "student"],
required: true
}
});For teachers and students, we also need to link the User document to their corresponding Teacher or Student document:
const userSchema = new mongoose.Schema({
name: String,
email: String,
password: String,
role: {
type: String,
enum: ["admin", "teacher", "student"],
required: true
},
// Link to the Teacher or Student document, depending on role
profileId: {
type: mongoose.Schema.Types.ObjectId,
refPath: 'role' // dynamically references "Teacher" or "Student" based on role
}
});refPath is a Mongoose feature for dynamic references — instead of always pointing to the same model, the ref is determined by the value of another field (role). However, refPath requires the field value to exactly match a model name. Since our roles are lowercase ("teacher") but model names are capitalized ("Teacher"), in practice you would store a separate field like profileModel: "Teacher" for this to work cleanly. For simplicity, many apps just store teacherId and studentId as separate optional fields instead.
Authorization Middleware
Building on the requireAuth middleware from the Authentication file, we add a requireRole middleware that checks the user's role:
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ message: 'Access denied — insufficient permissions' });
}
next();
};
}requireRole is a function that returns a middleware function — this lets you pass different allowed roles for different routes.
Using requireRole
const express = require('express');
const app = express();
// Only admins can create teachers
app.post('/api/teachers',
requireAuth,
requireRole('admin'),
async (req, res) => {
const teacher = await Teacher.create(req.body);
res.status(201).json(teacher);
}
);
// Admins and teachers can view course lists
app.get('/api/courses',
requireAuth,
requireRole('admin', 'teacher'),
async (req, res) => {
const courses = await Course.find();
res.json(courses);
}
);
// Any authenticated user can view their own profile
app.get('/api/profile',
requireAuth,
async (req, res) => {
const user = await User.findById(req.user.userId).select('-password');
res.json(user);
}
);401 Unauthorized means "we do not know who you are" — not logged in.
403 Forbidden means "we know who you are, but you cannot do this" — logged in but not allowed.
Resource-Level Authorization — "Own Data Only"
Role checks alone are not enough. A teacher should only update grades for their own courses — not every course in the school. This requires checking ownership of the specific resource being accessed.
Example — Teacher Can Only Update Their Own Course Grades
app.put('/api/courses/:courseId/grades',
requireAuth,
requireRole('admin', 'teacher'),
async (req, res) => {
const course = await Course.findById(req.params.courseId);
if (!course) {
return res.status(404).json({ message: 'Course not found' });
}
// Admins can update any course
if (req.user.role === 'admin') {
// proceed
}
// Teachers can only update their own course
else if (req.user.role === 'teacher') {
const teacher = await Teacher.findOne({ userId: req.user.userId });
if (!teacher || !course.teacherId.equals(teacher._id)) {
return res.status(403).json({ message: 'You can only update your own courses' });
}
}
// Proceed with grade update
const { studentId, subject, score } = req.body;
await Student.updateOne(
{ _id: studentId },
{ $push: { grades: { subject, score, semester: 1 } } }
);
res.json({ message: 'Grade updated' });
}
);Example — Student Can Only View Their Own Data
app.get('/api/students/:studentId',
requireAuth,
async (req, res) => {
const { studentId } = req.params;
// Admins and teachers can view any student
if (req.user.role === 'admin' || req.user.role === 'teacher') {
const student = await Student.findById(studentId);
return res.json(student);
}
// Students can only view their own record
if (req.user.role === 'student') {
const student = await Student.findOne({ userId: req.user.userId });
if (!student || !student._id.equals(studentId)) {
return res.status(403).json({ message: 'You can only view your own profile' });
}
return res.json(student);
}
res.status(403).json({ message: 'Access denied' });
}
);This is one of the most commonly missed security checks in real applications — known as an Insecure Direct Object Reference (IDOR). A developer adds requireAuth (is the user logged in?) but forgets to check ownership (is this the user's own data?). Without the ownership check, any logged-in student could view any other student's record just by changing the studentId in the URL.
Building a Permissions Table
For a clearer overview, define what each role can do as a permissions table — and reference it consistently across your routes:
const PERMISSIONS = {
admin: {
students: ['create', 'read', 'update', 'delete'],
teachers: ['create', 'read', 'update', 'delete'],
courses: ['create', 'read', 'update', 'delete'],
grades: ['read', 'update']
},
teacher: {
students: ['read'], // can view students
teachers: ['read'], // can view other teachers (limited)
courses: ['read', 'update'], // can update their own courses only
grades: ['read', 'update'] // can update grades for their courses only
},
student: {
students: ['read'], // own profile only
teachers: [],
courses: ['read'], // their enrolled courses only
grades: ['read'] // own grades only
}
};
function hasPermission(role, resource, action) {
return PERMISSIONS[role]?.[resource]?.includes(action) || false;
}// Usage in middleware
function requirePermission(resource, action) {
return (req, res, next) => {
if (!hasPermission(req.user.role, resource, action)) {
return res.status(403).json({ message: 'Access denied' });
}
next();
};
}
app.delete('/api/students/:id',
requireAuth,
requirePermission('students', 'delete'),
async (req, res) => {
await Student.findByIdAndDelete(req.params.id);
res.json({ message: 'Student deleted' });
}
);This permissions table approach centralizes your authorization rules — instead of scattered role checks across routes, you have one place that defines what every role can do.
Our School System — Authorization Summary
| Action | Admin | Teacher | Student |
|---|---|---|---|
| View all students | ✅ | ✅ | ❌ |
| View own profile | ✅ | ✅ | ✅ |
| Create/edit students | ✅ | ❌ | ❌ |
| Create/edit teachers | ✅ | ❌ | ❌ |
| View all courses | ✅ | ✅ | ✅ (enrolled only) |
| Edit own course | ✅ | ✅ (own only) | ❌ |
| Edit any course | ✅ | ❌ | ❌ |
| Update grades | ✅ | ✅ (own courses) | ❌ |
| View own grades | ✅ | ✅ | ✅ |
| View other students' grades | ✅ | ✅ | ❌ |
| Delete any record | ✅ | ❌ | ❌ |
Quick Reference
// Authentication — who are you?
app.use(requireAuth);
// Authorization — what role do you have?
app.use(requireRole('admin', 'teacher'));
// Resource-level — is this YOUR data?
if (!resource.ownerId.equals(req.user.userId)) {
return res.status(403).json({ message: 'Access denied' });
}
// Permission table — centralized rules
if (!hasPermission(req.user.role, 'students', 'delete')) {
return res.status(403).json({ message: 'Access denied' });
}Always think about authorization in three layers — role (what type of user is this?), resource (does this specific record belong to them?), and action (are they allowed to do this specific thing?). Missing any one of these three checks is how real security vulnerabilities happen — most commonly the resource-level "own data" check, which is easy to forget when a route works correctly for admins during testing.
Authentication
Learn the two layers of authentication in a MongoDB-backed app — database-level MongoDB authentication and application-level user authentication with hashed passwords and JWT.
Schema Validation as a Security Layer
Learn why MongoDB schema validation is also a security feature — defending against malformed data, oversized payloads, and type confusion attacks.