Week 7: Role-Based Access Control & OAuth

Week 6 answered "who is making this request?" with a requireAuth middleware and a req.user every route handler can trust. This week answers the next question — "is this specific user allowed to do this specific thing?" — by modeling roles on the User and layering an authorization middleware on top of last week's authentication one. Then you'll add a second way to establish that same req.user in the first place: OAuth social login with Passport.js, so a user can sign in with an existing Google account instead of a password at all. Both paths converge on the same JWT from Week 6, which is exactly what keeps every downstream route — including the testing work in Week 8 — from needing to know or care how a user authenticated.

Module 5 of 22 Week 7 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model roles on a User and write a requireRole authorization middleware
  • Set up a Passport.js OAuth strategy and understand the redirect/callback flow
  • Handle an OAuth callback to find-or-create a user and issue your own JWT from it

1. Modeling Roles & Permissions

Authentication (Week 6) proves identity; authorization decides what that identity is allowed to do. The simplest workable model is a single role enum on User — enough for most APIs, and easy to extend into a full permissions table later if you ever need per-action granularity instead of per-role:

prisma/schema.prisma
enum Role {
  USER
  EDITOR
  ADMIN
}

model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  name         String
  passwordHash String?  // nullable now -- OAuth users (Section 3) never set a password
  googleId     String?  @unique
  role         Role     @default(USER)
  posts        Post[]
  createdAt    DateTime @default(now())
}

USER can read and comment; EDITOR can create and publish posts; ADMIN can do both plus manage other users' roles. The enum's declaration order isn't meaningful to Prisma — role checks below compare against exact named values, never against numeric position — but it's worth writing them low to high privilege for humans reading the schema. Note passwordHash is now nullable: an OAuth-only user (Section 3) authenticates through Google and never sets a local password at all, so the column has to allow that case.

The JWT payload from Week 6 needs to carry the role too, since the authorization middleware in the next section reads it straight off req.user without a database round trip — update the login handler's jwt.sign call to include it:

src/routes/auth.ts — updated payload
const token = jwt.sign(
  { sub: user.id, email: user.email, role: user.role },
  JWT_SECRET,
  { expiresIn: "1h" },
);
A role in the JWT is a snapshot, not a live value

If an admin demotes a user mid-session, that user's existing token still carries their old role until it expires — Week 6's short expiresIn is exactly what bounds how long a stale role can stay valid. For anything more sensitive than a blog (banking, admin panels with destructive actions), re-checking the role against the database on high-stakes actions, rather than trusting the token alone, is the safer default.

2. An Authorization Middleware

requireRole is a small function that returns a middleware — a factory pattern, not a middleware itself — so you can parameterize which roles are allowed per route. It always runs after requireAuth, since it depends entirely on req.user already being set:

src/middleware/requireRole.ts
import type { Request, Response, NextFunction } from "express";

type Role = "USER" | "EDITOR" | "ADMIN";

export function requireRole(...allowed: Role[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      // requireAuth should always run first -- this means the routes were wired up wrong
      res.status(401).json({ error: "Authentication required" });
      return;
    }

    if (!allowed.includes(req.user.role)) {
      res.status(403).json({ error: "You don't have permission to perform this action" });
      return;
    }

    next();
  };
}
src/routes/posts.ts
import { requireAuth } from "../middleware/requireAuth.js";
import { requireRole } from "../middleware/requireRole.js";

// Any authenticated user can read; only EDITOR or ADMIN can create.
postsRouter.get("/", async (req, res, next) => { /* ... */ });

postsRouter.post(
  "/",
  requireAuth,
  requireRole("EDITOR", "ADMIN"),
  async (req, res, next) => { /* ... */ },
);

// Only ADMIN can change another user's role.
usersRouter.patch(
  "/:id/role",
  requireAuth,
  requireRole("ADMIN"),
  async (req, res, next) => { /* ... */ },
);

Stacking requireAuth then requireRole(...) as two separate middlewares — rather than one combined function — keeps each one doing exactly one job: authentication answers "who," authorization answers "what are they allowed to do." That separation is what lets you mix and match freely, like a route that's public to any logged-in user versus one gated to a specific role, without duplicating the token-verification logic in both.

401 vs. 403 is not a style choice

401 Unauthorized means "I don't know who you are" — no credentials, or invalid ones. 403 Forbidden means "I know exactly who you are, and the answer is still no." Returning 403 for a role mismatch (as above) rather than 401 tells a legitimate but under-privileged client not to bother retrying with a fresh token; the problem isn't their authentication, it's their permissions.

3. OAuth with Passport.js

OAuth lets a user authenticate through a provider they already trust — Google, in this example — instead of creating a password with you at all. Passport.js is the standard Express middleware for this: a "strategy" plugin per provider handles the redirect dance, and your app only has to react to the result.

terminal
npm install passport passport-google-oauth20
npm install -D @types/passport @types/passport-google-oauth20
src/lib/passport.ts
import passport from "passport";
import { Strategy as GoogleStrategy, type Profile } from "passport-google-oauth20";
import { prisma } from "./prisma.js";

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: "/auth/google/callback",
    },
    async (_accessToken: string, _refreshToken: string, profile: Profile, done) => {
      try {
        const email = profile.emails?.[0]?.value;
        if (!email) {
          return done(new Error("Google account has no email"));
        }

        // find-or-create: see Section 4 for why we match on profile.id, not just email
        let user = await prisma.user.findUnique({ where: { googleId: profile.id } });

        if (!user) {
          user = await prisma.user.create({
            data: {
              googleId: profile.id,
              email,
              name: profile.displayName,
              // passwordHash stays null -- this user only ever logs in via Google
            },
          });
        }

        done(null, user);
      } catch (err) {
        done(err as Error);
      }
    },
  ),
);

export { passport };
src/main.ts — mounting the routes
import { passport } from "./lib/passport.js";

app.use(passport.initialize()); // no passport.session() -- we issue our own JWT, not a cookie session

app.get(
  "/auth/google",
  passport.authenticate("google", { scope: ["profile", "email"], session: false }),
);

Hitting GET /auth/google redirects the browser to Google's consent screen; the scope array requests the specific pieces of profile data your strategy's callback needs. session: false everywhere in this setup is deliberate — Passport defaults to cookie-based sessions, but this app is already stateless via JWTs from Week 6, and mixing both session cookies and bearer tokens in one API adds complexity for no benefit here.

Never commit OAuth client secrets

GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET come from the Google Cloud Console and belong in your .env file, which should already be in .gitignore from Week 1's project setup. Anyone with the client secret can impersonate your app to Google's OAuth servers.

4. The Callback & Linking Accounts

After the user approves access on Google's consent screen, Google redirects the browser back to your callbackURL with an authorization code. Passport exchanges that code for the user's profile behind the scenes and hands it to the strategy's callback from Section 3 — your route handler runs after all of that, receiving the user Passport attached to req.user:

src/main.ts — the callback route
import jwt from "jsonwebtoken";
import type { User } from "@prisma/client";

app.get(
  "/auth/google/callback",
  passport.authenticate("google", { session: false, failureRedirect: "/login" }),
  (req, res) => {
    const user = req.user as User; // Passport's own req.user, distinct from Week 6's augmentation

    const token = jwt.sign(
      { sub: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET!,
      { expiresIn: "1h" },
    );

    // Hand the token to the frontend however your client expects it --
    // a redirect with the token in a query param is common for a first pass.
    res.redirect(`${process.env.FRONTEND_URL}/oauth-complete?token=${token}`);
  },
);

This is the key idea to hold onto: Google authenticating the user is not the same thing as your API authenticating them. The callback route still issues the exact same kind of JWT the password-based login route from Week 6 issues, using the exact same requireAuth middleware to verify it afterward. From the rest of the app's point of view, a user who signed in with Google and a user who signed in with a password are indistinguishable past this point — both arrive at every other route as a normal, verified req.user.

Matching the incoming Google profile to a User row by profile.id (Section 3), rather than by email alone, matters more than it looks: emails can be unverified, reused across providers, or changed later, while profile.id is a stable, provider-issued identifier that never changes for a given Google account. If you want to let an existing password-based user link a Google account rather than always creating a new one, do that lookup deliberately — check for an existing user by email first, and if found, set that user's googleId instead of creating a duplicate row:

src/lib/passport.ts — linking instead of duplicating
let user = await prisma.user.findUnique({ where: { googleId: profile.id } });

if (!user) {
  const existingByEmail = await prisma.user.findUnique({ where: { email } });

  if (existingByEmail) {
    // Same person, second login method -- link it instead of creating a duplicate account.
    user = await prisma.user.update({
      where: { id: existingByEmail.id },
      data: { googleId: profile.id },
    });
  } else {
    user = await prisma.user.create({
      data: { googleId: profile.id, email, name: profile.displayName },
    });
  }
}

5. Hands-on Exercise

Hands-on

Add roles and Google OAuth login to the blog API

Layer authorization on top of Week 6's authentication, then add a second, passwordless way to arrive at the same authenticated req.user.

Requirements:

  1. Add the Role enum and googleId field to User per Section 1, migrate, and update the login handler's JWT payload to include role.
  2. Implement requireRole per Section 2, and gate POST /posts to EDITOR/ADMIN and a new PATCH /users/:id/role to ADMIN only.
  3. Register a Google OAuth app in the Google Cloud Console (redirect URI http://localhost:3000/auth/google/callback for local dev), and wire up the strategy and routes from Sections 3–4.
  4. Implement the find-or-link-or-create logic from Section 4's second snippet, and manually verify all three paths: a brand-new Google login, a returning Google user, and an existing password-based user logging in with Google for the first time (should link, not duplicate).
  5. Confirm a USER-role account gets a 403 (not 401) from POST /posts, and that an unauthenticated request to the same route gets a 401.
Hint

For step 4's "existing password-based user" case, create a user via POST /auth/register first with a specific email, then run through the Google OAuth flow using a Google test account that shares that same email. Query the database afterward and confirm there's still exactly one row for that email, now with a non-null googleId.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is authorization written as a separate requireRole middleware layered after requireAuth, instead of one combined middleware that does both?

Keeping them separate means each middleware has exactly one job — requireAuth verifies identity, requireRole checks permission — which lets routes compose them differently: some routes need only authentication (any logged-in user), others need authentication plus a specific role. Combining them into one function would force every route to duplicate token-verification logic even for routes that only care about role, or vice versa.

Q2

Why does requireRole return 403 for a role mismatch instead of 401, and why does that distinction matter to the client?

401 means the server doesn't know who's making the request — no token, or an invalid one — and the fix is to authenticate again. 403 means the server knows exactly who the user is and has decided they're not allowed to do this specific thing regardless. Returning 403 tells the client not to bother retrying with a "fresher" token, since the problem is the user's role, not their credentials.

Q3

Why does the OAuth callback route still call jwt.sign(...) even though Google has already authenticated the user?

Google authenticating the user only proves their identity to Google, for the duration of that one redirect flow — it says nothing to the rest of your API on subsequent requests. Issuing your own JWT at the end of the callback converts that one-time proof into the same kind of credential the password-based login route produces, so every other route in the app can keep using requireAuth without needing to know or care which method the user originally signed in with.

Q4

Why match an incoming Google profile to a User row by profile.id rather than by email alone?

profile.id is a stable identifier Google issues once per account and never changes, while email addresses can be unverified, changed later, or in principle reused, making them a shakier key to permanently identify an account by. Matching on profile.id first, and only falling back to an email lookup when deliberately trying to link an existing password-based account (as in Section 4's second snippet), avoids silently merging two different people's accounts just because an email happened to collide.