diff --git a/package.json b/package.json
index cde392aaa..b855887a1 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
   "dependencies": {
     "@babel/plugin-proposal-class-properties": "^7.18.6",
     "@babel/plugin-proposal-decorators": "^7.17.8",
+    "@nestjs/throttler": "^5.0.1",
     "cross-env": "^7.0.3",
     "nodemailer": "^6.9.1"
   },
diff --git a/packages/api-v2/src/app.module.ts b/packages/api-v2/src/app.module.ts
index 6b8c20b91..69b3118d4 100644
--- a/packages/api-v2/src/app.module.ts
+++ b/packages/api-v2/src/app.module.ts
@@ -5,10 +5,11 @@ import ormconfig from "../ormconfig";
 import { StudentModule } from "./student/student.module";
 import { AuthModule } from "./auth/auth.module";
 import { PlanModule } from "./plan/plan.module";
-import { APP_INTERCEPTOR } from "@nestjs/core";
+import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
 import { LoggingInterceptor } from "./interceptors/logging.interceptor";
 import { MajorModule } from "./major/major.module";
 import { EmailModule } from "./email/email.module";
+import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
 
 @Module({
   imports: [
@@ -17,6 +18,12 @@ import { EmailModule } from "./email/email.module";
       envFilePath: [`.env.${process.env.NODE_ENV}.local`],
       isGlobal: true,
     }),
+    ThrottlerModule.forRoot([
+      {
+        ttl: 60000, // no more than 100 requests in a minute
+        limit: 100,
+      },
+    ]),
     StudentModule,
     AuthModule,
     PlanModule,
@@ -28,6 +35,10 @@ import { EmailModule } from "./email/email.module";
       provide: APP_INTERCEPTOR,
       useClass: LoggingInterceptor,
     },
+    {
+      provide: APP_GUARD,
+      useClass: ThrottlerGuard,
+    },
   ],
 })
 export class AppModule {}
diff --git a/packages/api-v2/src/auth/auth.controller.ts b/packages/api-v2/src/auth/auth.controller.ts
index fbc8f6825..4bdcdb161 100644
--- a/packages/api-v2/src/auth/auth.controller.ts
+++ b/packages/api-v2/src/auth/auth.controller.ts
@@ -30,6 +30,7 @@ import {
   WeakPassword,
 } from "src/student/student.errors";
 import { BadToken, InvalidPayload, TokenExpiredError } from "./auth.errors";
+import { Throttle } from "@nestjs/throttler";
 
 @Controller("auth")
 export class AuthController {
@@ -74,6 +75,7 @@ export class AuthController {
     return student;
   }
 
+  @Throttle({ default: { limit: 20, ttl: 60000 } }) // restrict to no more than 20 requests per minute
   @Post("login")
   public async login(
     @Res({ passthrough: true }) response: Response,
diff --git a/yarn.lock b/yarn.lock
index 60d975fc0..827960548 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3782,6 +3782,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@nestjs/throttler@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "@nestjs/throttler@npm:5.0.1"
+  dependencies:
+    md5: ^2.2.1
+  peerDependencies:
+    "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
+    "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
+    reflect-metadata: ^0.1.13
+  checksum: 9649fb7ea54ed97d552745b477e06bcbcc4f2ee1464af2e54c149ec8c1dab76226786013c75844589992bf2cd566d21bad11b3fbb0cd0c63ad10525361f2fae5
+  languageName: node
+  linkType: hard
+
 "@nestjs/typeorm@npm:^8.0.2":
   version: 8.1.4
   resolution: "@nestjs/typeorm@npm:8.1.4"
@@ -5848,6 +5861,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"charenc@npm:0.0.2":
+  version: 0.0.2
+  resolution: "charenc@npm:0.0.2"
+  checksum: 81dcadbe57e861d527faf6dd3855dc857395a1c4d6781f4847288ab23cffb7b3ee80d57c15bba7252ffe3e5e8019db767757ee7975663ad2ca0939bb8fcaf2e5
+  languageName: node
+  linkType: hard
+
 "chokidar@npm:3.5.3, chokidar@npm:^3.4.0, chokidar@npm:^3.5.3":
   version: 3.5.3
   resolution: "chokidar@npm:3.5.3"
@@ -6317,6 +6337,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"crypt@npm:0.0.2":
+  version: 0.0.2
+  resolution: "crypt@npm:0.0.2"
+  checksum: baf4c7bbe05df656ec230018af8cf7dbe8c14b36b98726939cef008d473f6fe7a4fad906cfea4062c93af516f1550a3f43ceb4d6615329612c6511378ed9fe34
+  languageName: node
+  linkType: hard
+
 "css-box-model@npm:1.2.1":
   version: 1.2.1
   resolution: "css-box-model@npm:1.2.1"
@@ -8011,6 +8038,7 @@ __metadata:
     "@babel/plugin-proposal-decorators": ^7.17.8
     "@babel/preset-env": ^7.16.11
     "@babel/preset-typescript": ^7.16.7
+    "@nestjs/throttler": ^5.0.1
     "@types/nodemailer": ^6.4.7
     "@typescript-eslint/eslint-plugin": ^5.15.0
     "@typescript-eslint/parser": ^5.15.0
@@ -8501,6 +8529,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-buffer@npm:~1.1.6":
+  version: 1.1.6
+  resolution: "is-buffer@npm:1.1.6"
+  checksum: 4a186d995d8bbf9153b4bd9ff9fd04ae75068fe695d29025d25e592d9488911eeece84eefbd8fa41b8ddcc0711058a71d4c466dcf6f1f6e1d83830052d8ca707
+  languageName: node
+  linkType: hard
+
 "is-callable@npm:^1.1.4, is-callable@npm:^1.2.6":
   version: 1.2.7
   resolution: "is-callable@npm:1.2.7"
@@ -9749,6 +9784,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"md5@npm:^2.2.1":
+  version: 2.3.0
+  resolution: "md5@npm:2.3.0"
+  dependencies:
+    charenc: 0.0.2
+    crypt: 0.0.2
+    is-buffer: ~1.1.6
+  checksum: a63cacf4018dc9dee08c36e6f924a64ced735b37826116c905717c41cebeb41a522f7a526ba6ad578f9c80f02cb365033ccd67fe186ffbcc1a1faeb75daa9b6e
+  languageName: node
+  linkType: hard
+
 "mdast-util-from-markdown@npm:^1.2.0":
   version: 1.2.0
   resolution: "mdast-util-from-markdown@npm:1.2.0"