diff --git a/apps/blog/content/blog/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/index.mdx b/apps/blog/content/blog/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/index.mdx
index 01d7025c6e..330a72f1bc 100644
--- a/apps/blog/content/blog/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/index.mdx
+++ b/apps/blog/content/blog/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/index.mdx
@@ -1,40 +1,37 @@
---
-title: "Backend with TypeScript, PostgreSQL & Prisma: Authentication & Authz"
+title: "Backend with TypeScript, PostgreSQL & Prisma: Authentication & Authorization"
slug: "backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4"
date: "2020-09-10"
+updatedAt: "2026-07-03"
authors:
- "Daniel Norman"
metaTitle: "TypeScript, PostgreSQL, Prisma Backend | Authentication, Authorization"
-metaDescription: "Secure a TypeScript and PostgreSQL backend with Prisma, passwordless authentication, JWTs, and authorization rules using Hapi."
+metaDescription: "Secure a TypeScript and PostgreSQL backend with Prisma ORM 7, passwordless authentication, JWTs, and authorization rules using Hono."
metaImagePath: "/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/imgs/meta-9eae0d75afddb1fce65c68320479268f4cdcffd3-1692x852.png"
heroImagePath: "/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4/imgs/hero-9f73d35216a42b2bb796a971053669fbc90e36ee-846x426.png"
-heroImageAlt: "Backend with TypeScript, PostgreSQL & Prisma: Authentication & Authz"
+heroImageAlt: "Backend with TypeScript, PostgreSQL & Prisma: Authentication & Authorization"
series: backend-prisma-typescript-orm-with-postgresql
seriesIndex: 3
---
-In this third part of the series, we'll look at how to secure the REST API with passwordless authentication using Prisma for token storage and implement authorization.
+> **Update (July 2026):** This article was updated for Prisma ORM 7. Authentication and authorization are now built with [Hono](https://hono.dev/) middleware and Hono's built-in [JWT helper](https://hono.dev/docs/helpers/jwt) instead of Hapi. It uses the rust-free `prisma-client` generator, `prisma.config.ts`, and driver adapters. All commands and code work verbatim on Prisma ORM 7 with Node.js 20 or later.
-
+In this third part of the series, you'll secure the REST API with passwordless authentication, using Prisma for token storage, and implement authorization.
## Introduction
-The goal of the series is to explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: **a grading system for online courses.** This is a good example because it features diverse relations types and is complex enough to represent a real-world use case.
+The goal of the series is to explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: **a grading system for online courses.**
-The recording of the live stream is available above and covers the same ground as this article.
-
-### What the series will cover
-
-The series will focus on the role of the database in every aspect of backend development covering:
+### What the series covers
| Topic | Part |
| ------------------------------ | ------------------------------------------------------------------- |
-| Data Modeling | [Part 1](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
-| CRUD | [Part 1](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
-| Aggregations | [Part 1](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
-| REST API layer | [Part 2](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
-| Validation | [Part 2](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
-| Testing | [Part 2](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
+| Data Modeling | [Part 1](/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
+| CRUD | [Part 1](/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
+| Aggregations | [Part 1](/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) |
+| REST API layer | [Part 2](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
+| Validation | [Part 2](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
+| Testing | [Part 2](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) |
| Passwordless Authentication | Part 3 (current) |
| Authorization | Part 3 (current) |
| Integration with external APIs | Part 3 (current) |
@@ -42,1057 +39,636 @@ The series will focus on the role of the database in every aspect of backend dev
### What you will learn today
-In the first article, you designed a [data model](/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) for the problem domain and wrote a seed script that uses [Prisma Client](https://www.prisma.io/docs/orm/prisma-client) to save data to the database.
+In the first article, you designed a [data model](/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) and wrote a seed script that uses [Prisma Client](https://www.prisma.io/docs/orm/prisma-client) to save data.
-In the second article of the series, you built a [REST API](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) on top of the data model and [Prisma schema](https://www.prisma.io/docs/orm/prisma-schema) from the first article. You used [Hapi](https://hapi.dev/) to build the REST API, which allowed performing CRUD operations on resources via HTTP requests.
+In the second article, you built a [REST API](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3) with [Hono](https://hono.dev/) on top of that data model.
-In this third article of the series, you will learn about the concepts behind authentication and authorization, how the two differ, and how to implement email-based passwordless authentication and authorization using [JSON Web Tokens (JWT)](https://jwt.io/) with Hapi to secure the REST API.
+In this third article, you will learn the concepts behind authentication and authorization, how they differ, and how to implement email-based passwordless authentication and authorization using [JSON Web Tokens (JWT)](https://jwt.io/) with Hono to secure the REST API.
Concretely, you will develop the following aspects:
-1. **Passwordless Authentication:** Add the ability to log in and sign up by sending an email with a unique token. Users complete the authentication process by sending the token received by email to the API and getting back a long-lived JWT token, giving access to API endpoints that require authentication.
+1. **Passwordless Authentication:** Add the ability to log in and sign up by sending an email with a unique token. Users complete the process by sending the token back to the API and receiving a long-lived JWT, which grants access to endpoints that require authentication.
2. **Authorization:** Add authorization logic to restrict which resources users can access and manipulate.
-By the end of this article, the REST API will be secured with authentication to access the REST endpoints. Additionally, you will add authorization rules to a subset of the endpoints using Hapi's [`pre`](https://hapi.dev/api/?v=20.0.0#-routeoptionspre) route option, thereby granting access depending on the specific user's permissions. The API with the authorization rules for all the endpoints will be available in this [GitHub repository](https://github.com/2color/real-world-grading-app).
+By the end of this article, the REST API will be secured with authentication, and a subset of endpoints will have authorization rules granting access based on the user's permissions.
> **Note:** Throughout the guide, you'll find various **checkpoints** that enable you to validate whether you performed the steps correctly.
## Prerequisites
-### Assumed knowledge
-
-This series assumes basic knowledge of TypeScript, Node.js, and relational databases. If you're experienced with JavaScript but haven't had the chance to try TypeScript, you can still follow along. The series will use PostgreSQL. However, most of the concepts apply to other relational databases such as MySQL. Familiarity with REST concepts is useful. Beyond that, no prior knowledge of Prisma is required, as the series covers that.
-
-### Development environment
-
-You should have the following installed:
-
-- [Node.js](https://nodejs.org/en/) (version 10, 12, or 14)
-- [Docker](https://www.docker.com/) (will be used to run a development PostgreSQL database)
-
-If you're using Visual Studio Code, the [Prisma extension](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) is recommended for syntax highlighting, formatting, and other helpers.
-
-> **Note**: If you don't want to use Docker, you can set up a [local PostgreSQL database](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database) or a [hosted PostgreSQL database on Heroku](https://dev.to/prisma/how-to-setup-a-free-postgresql-database-on-heroku-1dc1).
-
-### External services
-
-A [SendGrid](https://sendgrid.com/) account is required so that you can send the passwordless authentication emails from the backend. SendGrid offers a free tier that allows sending up to 100 emails a day.
-
-Once you [sign up](https://signup.sendgrid.com/), go to [API Keys](https://app.sendgrid.com/settings/api_keys) in the SendGrid console, generate an API key, and keep it somewhere safe.
-
-## Clone the repository
-
-The source code for the series can be found on [GitHub](https://github.com/2color/real-world-grading-app).
-
-To get started, clone the repository and install the dependencies:
-```
-git clone -b part-3 git@github.com:2color/real-world-grading-app.git
-cd real-world-grading-app
-npm install
-```
-> **Note:** By checking out the `part-3` branch you'll be able to follow the article from the same starting point.
+This article continues from [part 2](/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3). You should have the `grading-app` project with the Hono REST API, the migrated Prisma Postgres database, and the tests from part 2. You'll need Node.js 20 or later.
-## Start PostgreSQL
+### External services (optional)
-To start PostgreSQL, run the following command from the `real-world-grading-app` folder:
+The backend sends a login token by email. To send real emails, this article uses [Resend](https://resend.com/), which has a free tier. Sign up, create an API key, and keep it safe.
-```sh
-docker-compose up -d
-```
-> **Note:** Docker will use the [`docker-compose.yml`](https://github.com/2color/real-world-grading-app/blob/21de326008776144ced60427a055c9fc54a32840/docker-compose.yml) file to start the PostgreSQL container.
+You don't need an email provider to follow along, though: when no `RESEND_API_KEY` is set, the backend logs the token to the console instead of sending it, so you can complete the whole flow locally.
## Authentication and authorization concepts
-Before diving into the implementation, we'll go through some concepts relating to authentication and authorization.
+Before diving into the implementation, here are some concepts relating to authentication and authorization.
-While the two terms are often used interchangeably, authentication and authorization serve different purposes. Generally speaking, they are both used to secure applications in complementary ways.
+While the two terms are often used interchangeably, they serve different, complementary purposes. Put simply, _authentication_ is the process of verifying **who** a user is, while _authorization_ is the process of verifying **what they have access to**.
-Put simply, _authentication_ is the process of verifying **who** a user is, while _authorization_ is the process of verifying **what they have access to**.
-
-One example of _authentication_ in the real world is a valid passport. The fact that you look like the person in an official document (that is hard to forge) authenticates that you are who you claim to be. For example, when you go to the airport, you present your passport, and you're allowed to go through security.
-
-In the same example, _authorization_ is the process by which you are allowed to board the flight: you present your boarding pass (that is typically scanned and verified against the database of flight passengers), and the ground attendant authorizes you to board the flight.
+One example of _authentication_ in the real world is a valid passport. The fact that you look like the person in an official document (that is hard to forge) authenticates that you are who you claim to be. In the same example, _authorization_ is the process by which you are allowed to board the flight: you present your boarding pass, which is verified against the database of flight passengers, and the ground attendant authorizes you to board.
### Authentication in web applications
-Web applications typically use a username and password to authenticate users. If a valid username and password are passed, the application can verify that you're the user you claim to be because the password is supposed to be only known to you and the application.
-
-> **Note:** Web applications that use username/password authentication rarely store the password in clear text in the database.
-> Instead, they use a technique called _hashing_ to store a hash of the password.
-> This allows the backend to verify the password without knowing it.
->
-> A hash function is a mathematical function that takes arbitrary input and always generates the same fixed-length string/number given the same input.
-> The power of hash function lies in that you can go from a password to a hash but not from a hash to a password.
->
-> This allows verifying the password submitted by the user without storing the actual password.
-> Storing password hashes protects users in the case of breached access to the database because it's not possible to log in with the hashed password.
+Web applications typically use a username and password to authenticate users. If a valid username and password are passed, the application can verify you're the user you claim to be, because the password is supposed to be known only to you and the application.
-In recent years, web security has become a growing concern, given the number of significant websites that [have been breached](https://haveibeenpwned.com/PwnedWebsites). This trend has influenced how security is approached by introducing more secure authentication approaches such as [_multi-factor authentication_](https://en.wikipedia.org/wiki/Multi-factor_authentication).
+> **Note:** Web applications that use username/password authentication rarely store the password in clear text. Instead, they store a _hash_ of the password. A hash function takes arbitrary input and always produces the same fixed-length output for the same input, but you cannot go from a hash back to the password. This lets the backend verify a password without storing it, which protects users if the database is breached.
-Multi-factor authentication is an authentication method where the user is authenticated after successfully presenting two or more pieces of evidence (also known as factors). For example, when withdrawing money from an ATM, two authentication factors are required: possession of a bank card and a PIN code.
+In this article, you will implement email-based passwordless authentication, a two-step approach that improves both user experience and security. It works by sending a secret token to the user's email account when they attempt to log in. Once the user retrieves the token and passes it back to the application, the application can be confident the user owns that email account.
-Since possession of a card is hard to verify for a web application, multi-factor authentication is often implemented by supplementing username/password with a one-time token generated by an authenticator app (an app installed on a smartphone or a special device which generates these passwords).
-
-In this article, you will implement email-based passwordless authentication – a two-step approach that improves user experience and security. It works by sending a secret token to the user's email account when attempting to log in. Once the user opens the email and passes the token to the application, the application can authenticate the user and be certain that the user is the email account owner.
-
-This approach relies on the user's email service, which can be assumed to have already authenticated the user. User experience is improved as the user doesn't need to set a password and remember it. Security is enhanced as the application is relieved from password management responsibilities, which can be an attack surface.
-
-Outsourcing authentication to a user's email account means that the application will inherit the benefits and weaknesses of the user's email account security. But these days, most email services provide the option of second-factor authentication and other security measures.
-
-Still, this approach avoids users choosing weak passwords, and likely reusing them on multiple websites. Removing passwords altogether means these users are more secure. There is no longer a password that can be guessed or brute-forced or cracked at all.
+This approach relies on the user's email service, which can be assumed to have already authenticated the user. The user doesn't need to choose or remember a password, and the application is relieved of password management, which is a common attack surface.
### Authentication and signup/login flow
Email-based passwordless authentication is a two-step process that involves two token types.
-The authentication flow will look as follows:
-

-1. The user calls the `/login` endpoint in the API with the email in the payload to begin the authentication process.
-2. If the email is new, the user is created in the _User_ table.
-3. An email token is generated by the backend and saved in the _Token_ table
-4. The email token is sent to the user's email
-5. The user sends the email token (received via email), and the email address to the `/authenticate` endpoint
-6. The backend validates the email token sent by the user. If valid and the token hasn't expired, a JWT token is generated and saved in the _Token_ table.
-7. The JWT token is sent back to the user via the `Authorization` header.
+1. The user calls the `/login` endpoint with their email to begin the process.
+2. If the email is new, the user is created in the `User` table.
+3. An email token is generated by the backend and saved in the `Token` table.
+4. The email token is sent to the user's email.
+5. The user sends the email token and their email address to the `/authenticate` endpoint.
+6. The backend validates the email token. If it's valid and hasn't expired, a JWT is generated and a corresponding token row is saved in the `Token` table.
+7. The JWT is sent back to the user in the `Authorization` header.
There are two token types:
-1. **Email token:** A numerical eight-digit token that is valid for a short period, e.g. 10 minutes, and is sent to the user's email. The token's only purpose is to validate the user is associated with the email, which means it doesn't grant access to any of the grading-app related endpoints.
-2. **Authentication token:** A JWT token with `tokenId` in its payload. This token can be used to access protected endpoints by passing it in the `Authorization` header when making a request to the API. The token is long-lived in the sense that it's valid for 12 hours.
+1. **Email token:** A numerical eight-digit token that is valid for a short period, for example 10 minutes, and is sent to the user's email. Its only purpose is to validate that the user owns the email address; it doesn't grant access to any grading-app endpoints.
+2. **Authentication token:** A JWT with a `tokenId` in its payload. This token is passed in the `Authorization` header to access protected endpoints, and it's long-lived (valid for 12 hours).
-With this authentication strategy, a single endpoint handles both logging in and registration. It's is possible because the only difference between logging in and signing up is whether you're creating a row in the "User" table or not (if the user already exists).
+With this strategy, a single flow handles both logging in and registration. It works because the only difference between the two is whether a row is created in the `User` table.
### JSON Web Tokens
-[JSON Web Tokens (JWT)](https://jwt.io/) are an open and standard method for representing claims securely between two parties.
-The standard defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
-
-A JWT token contains three parts which are encoded with Base64: _header_, _payload_, and _signature_ and looks as follows (the parts are separated with a `.`):
-```
-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
-eyJ0b2tlbklkIjo5fQ.
-FkKMzLobPl_MaQHB7hRG3nZQZ-ME4lRaanGJVnLMa84
-```
-> Note: Base64 is another way to represent data. It doesn't involve any encryption
-
-If you use Base64 to decode the header and payload from above you will get the following:
-
-- Header: `{"alg":"HS256","typ":"JWT"}`
-- Payload: `{"tokenId":9}`
-
-The token's signature part is created by passing the _header_, _payload_, and **secret** through a signing algorithm (in this case, HS256). The secret is only known to the backend and is used to verify the authenticity of the token.
+[JSON Web Tokens (JWT)](https://jwt.io/) are an open standard for representing claims securely between two parties. A JWT contains three Base64-encoded parts separated by a `.`: the _header_, the _payload_, and the _signature_.
-In this article, JWT will be used for the long-lived authentication token. The token's payload will contain the `tokenId`, which will be stored in the database and reference to the user for which the token was created. This allows the backend to find the associated user.
+The signature is created by passing the header, payload, and a **secret** through a signing algorithm (here, HS256). The secret is known only to the backend and is used to verify the token's authenticity.
-> **Note:** This approach is known as stateful JWT, where the token references a session that is stored in the database. While that means that authenticating a request requires a round-trip to the database, which increases the time needed to serve a request, this approach is more secure because tokens can be revoked from by the backend.
+In this article, the JWT payload contains a `tokenId` that references a token row in the database.
-## Adding a token model to the Prisma schema
+> **Note:** This approach is known as _stateful JWT_, where the token references a session stored in the database. Authenticating a request requires a database lookup, but this is more secure because tokens can be revoked by the backend.
-You need to store the tokens in the database so they can be verified when requests are made. In this step, you will add a new `Token` model to the Prisma schema and update the `User` model to make some fields optional.
+## Adding a Token model to the Prisma schema
-Open the Prisma schema located in `prisma/schema.prisma` and update as follows:
+You need to store tokens in the database so they can be verified when requests are made. Open `prisma/schema.prisma` and make three changes: make `firstName` and `lastName` optional (so users can register with just an email), add an `isAdmin` flag to `User`, and add a new `Token` model.
```prisma
-diff
-generator client {
- provider = "prisma-client-js"
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ firstName String?
+ lastName String?
+ social Json?
+ isAdmin Boolean @default(false)
+
+ // Relation fields
+ courses CourseEnrollment[]
+ testResults TestResult[] @relation(name: "results")
+ testsGraded TestResult[] @relation(name: "graded")
+ tokens Token[]
}
-model User {
- id Int @id @default(autoincrement())
- email String @unique
-- firstName String
-- lastName String
-+ firstName String?
-+ lastName String?
- social Json?
-
- // Relation fields
- courses CourseEnrollment[]
- testResults TestResult[] @relation(name: "results")
- testsGraded TestResult[] @relation(name: "graded")
-+ tokens Token[]
+model Token {
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ type TokenType
+ emailToken String? @unique // Only used for short-lived email tokens
+ valid Boolean @default(true)
+ expiration DateTime
+
+ // Relation fields
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId Int
}
-+model Token {
-+ id Int @id @default(autoincrement())
-+ createdAt DateTime @default(now())
-+ updatedAt DateTime @updatedAt
-+ type TokenType
-+ emailToken String? @unique // Only used for short lived email tokens
-+ valid Boolean @default(true)
-+ expiration DateTime
-
-+ // Relation fields
-+ user User @relation(fields: [userId], references: [id])
-+ userId Int
-+}
-
-+enum TokenType {
-+ EMAIL // used as a short-lived token sent to the user's email
-+ API
-+}
+enum TokenType {
+ EMAIL // short-lived token sent to the user's email
+ API // long-lived JWT-referenced token
+}
```
-Let's go over the changes introduced:
-- Enable the [`connectOrCreate`](https://www.prisma.io/docs/orm/reference/prisma-client-reference#connectorcreate) and [`transactionApi`](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#the-transaction-api) preview features. These will be used in the next steps.
-- Remove the `aggregateApi` preview feature, which is stable as of [Prisma 2.5.0](https://github.com/prisma/prisma/releases/tag/2.5.0).
-- In the `User` model the `firstName` and `lastName` are now optional. This allows users to log in/register with just an email.
-- A new `Token` model was added. Each user can have many tokens making the relation a 1-n. The `Token` model contains the relevant fields accomodating for expiration, the two token types (with the `TokenType` enum), and storage of the email token.
+Each user can have many tokens, making the relation a one-to-many. `onDelete: Cascade` means that deleting a user also removes their tokens, so the `DELETE /users/{userId}` endpoint from part 2 keeps working once users have tokens.
-To migrate the database schema, create and run the migration as follows:
+> **Note:** Unlike the Prisma 2 era, none of this needs preview feature flags. `connectOrCreate`, transactions, and aggregates are all stable in Prisma ORM 7.
-```npm
-npx prisma migrate dev --preview-feature --name "add-token"
-```
-**Checkpoint:** You should see something like the following in the output:
-```
-Prisma Migrate created and applied the following migration(s) from new schema changes:
+Create and run the migration:
-migrations/
- └─ 20201202094612_add_token/
- └─ migration.sql
-
-✔ Generated Prisma Client to ./node_modules/@prisma/client in 96ms
-
-Everything is now in sync.
+```sh
+npx prisma migrate dev --name add-token
```
-> **Note:** Running the `prisma migrate dev` command will also generate Prisma Client by default.
-## Add email sending functionality
+**Checkpoint:** You should see output similar to:
-Since the backend will send emails upon user login, you will create a plugin that will expose email sending functionality to the rest of the application. The Hapi plugin will follow a similar convention to the Prisma plugin.
-
-The article will use SendGrid and the `@sendgrid/mail` npm package for easy integration with the SendGrid API.
-
-### Adding the dependency
+```
+migrations/
+ └─ 20260703131449_add_token/
+ └─ migration.sql
-```npm
-npm install --save @sendgrid/mail
+Your database is now in sync with your schema.
```
-### Creating the email plugin
-Create a new file named `email.ts` in the `src/plugins/` folder:
+## Add email sending
-```sh
-touch src/plugins/email.ts
-```
-And add the following to the file:
+The backend sends the login token by email. Create `src/lib/email.ts`:
```ts
-import Hapi from '@hapi/hapi'
-import Joi from '@hapi/joi'
-import Boom from '@hapi/boom'
-import sendgrid from '@sendgrid/mail'
-
-// Module augmentation to add shared application state
-// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33809#issuecomment-472103564
-declare module '@hapi/hapi' {
- interface ServerApplicationState {
- sendEmailToken(email: string, token: string): Promise
+// Sends the short-lived login token to the user's email.
+// In development, when RESEND_API_KEY is not set, the token is logged to the
+// console instead of being sent, so you can follow the flow without an email
+// provider. In production, set RESEND_API_KEY to send real emails.
+export async function sendEmailToken(email: string, token: string): Promise {
+ if (!process.env.RESEND_API_KEY) {
+ console.log(`email token for ${email}: ${token}`);
+ return;
}
-}
-const emailPlugin = {
- name: 'app/email',
- register: async function(server: Hapi.Server) {
- if (!process.env.SENDGRID_API_KEY) {
- console.log(
- `The SENDGRID_API_KEY env var must be set, otherwise the API won't be able to send emails.`,
- `Using debug mode which logs the email tokens instead.`,
- )
- server.app.sendEmailToken = debugSendEmailToken
- } else {
- sendgrid.setApiKey(process.env.SENDGRID_API_KEY)
- server.app.sendEmailToken = sendEmailToken
- }
- },
-}
-
-export default emailPlugin
-
-async function sendEmailToken(email: string, token: string) {
- const msg = {
- to: email,
- from: 'EMAIL_ADDRESS_CONFIGURED_IN_SEND_GRID@email.com',
- subject: 'Login token for the modern backend API',
- text: `The login token for the API is: ${token}`,
+ const res = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: "login@your-domain.com",
+ to: email,
+ subject: "Login token for the grading app API",
+ text: `Your login token is: ${token}`,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to send email: ${res.status} ${await res.text()}`);
}
-
- await sendgrid.send(msg)
-}
-
-async function debugSendEmailToken(email: string, token: string) {
- console.log(`email token for ${email}: ${token} `)
}
```
-The plugin will expose the `sendEmailToken` function on the `server.app` object, which is accessible throughout your route handlers. It will use the `SENDGRID_API_KEY` environment variable, which you will set in production using the key from the SendGrid console. During development, you can leave it unset, and the token will be logged instead of being sent via email.
-
-Lastly, register the plugin in `server.ts`:
-
-```ts
-import emailPlugin from './plugins/email'
-
-await server.register([
- // ... existing plugins
- emailPlugin,
-])
-```
-## Adding authentication with Hapi
-To implement authentication you will begin by defining the `/login` and `/register` routes, which will handle the creation of a user and token in the database, sending the email token, verifying the email, and generating a JWT authentication token. It's worth noting that the two endpoints will handle the authentication process, but they will not secure the API.
+Unlike the Hapi version, which registered email as a server plugin, this is a plain function you import where you need it. It logs the token in development and sends a real email once `RESEND_API_KEY` is set.
-To secure the API, once the two routes are defined, you will define an authentication strategy that uses the `jwt` scheme provided by the [`hapi-auth-jwt2`](https://github.com/dwyl/hapi-auth-jwt2) library.
+## Authentication with Hono
-> **Note:** Authentication in Hapi is based on the concept of **schemes** and **strategies**. Schemes are a way of handling authentication, whereas a strategy is a pre-configured instance of a schema. In this article, you will only need to define the strategy based on the `jwt` authentication scheme.
+In Hapi, authentication was configured with _schemes_ and _strategies_. Hono takes a simpler, more explicit approach: authentication is just [middleware](https://hono.dev/docs/guides/middleware). You'll write one middleware that verifies the JWT and loads the user, and apply it to the routes you want to protect. Hono also ships a built-in [JWT helper](https://hono.dev/docs/helpers/jwt), so you don't need any extra libraries to sign or verify tokens.
-You will encapsulate all of this logic in an `auth` plugin.
+### Auth helpers and middleware
-### Adding the dependencies
+Create `src/lib/auth.ts`. It holds the token helpers, the authentication middleware, and the authorization middlewares you'll add shortly:
-Begin by adding the following dependencies to your project:
+```ts
+import { sign, verify } from "hono/jwt";
+import type { Context, Next } from "hono";
+import type { AppEnv } from "./prisma";
-```npm
-npm install --save hapi-auth-jwt2@10.1.0 jsonwebtoken@8.5.1
-npm install --save-dev @types/jsonwebtoken@8.5.0
-```
-### Creating the auth plugin
+const JWT_SECRET = process.env.JWT_SECRET || "SUPER_SECRET_JWT_SECRET";
+const JWT_ALGORITHM = "HS256" as const;
-Next, you will create an auth plugin to encapsulate the authentication logic.
+const EMAIL_TOKEN_EXPIRATION_MINUTES = 10;
+const AUTH_TOKEN_EXPIRATION_HOURS = 12;
-Create a new file named `auth.ts` in the `src/plugins/` folder:
+export const emailTokenExpiration = () =>
+ new Date(Date.now() + EMAIL_TOKEN_EXPIRATION_MINUTES * 60 * 1000);
-```sh
-touch src/plugins/auth.ts
-```
-And add the following to the file:
+export const authTokenExpiration = () =>
+ new Date(Date.now() + AUTH_TOKEN_EXPIRATION_HOURS * 60 * 60 * 1000);
-```ts
-import Hapi from '@hapi/hapi'
-import { TokenType, UserRole } from '@prisma/client'
-
-const authPlugin: Hapi.Plugin = {
- name: 'app/auth',
- dependencies: ['prisma', 'hapi-auth-jwt2', 'app/email'],
- register: async function(server: Hapi.Server) {
- // TODO: Add the authentication strategy
- },
+// Generate a random 8-digit number as the email token.
+export function generateEmailToken(): string {
+ return Math.floor(10000000 + Math.random() * 90000000).toString();
}
-export default plugin
-```
-> **Note:** The auth plugin defines dependencies on the `prisma`, `hapi-auth-jwt2`, and `app/email` plugins. The prisma plugin was defined in part 2 of the series and will be used to access Prisma Client. The `hapi-auth-jwt2` plugin defines the `jwt` authentication scheme, which you will use to define the authentication the strategy. Lastly, the `app/email` will ensure you can access the `sendEmailToken` function.
+// Sign a JWT that references the API token stored in the database.
+export function generateAuthToken(tokenId: number): Promise {
+ return sign({ tokenId }, JWT_SECRET, JWT_ALGORITHM);
+}
-### Defining the login endpoint
+// Runs on every protected request: verifies the JWT, checks the referenced
+// token in the database, and attaches the user's credentials to the context.
+export async function authMiddleware(c: Context, next: Next) {
+ const authHeader = c.req.header("Authorization");
+ if (!authHeader) {
+ return c.json({ error: "Missing authentication" }, 401);
+ }
-In the `register` function of `authPlugin`, define a new login route as follows:
+ const token = authHeader.replace(/^Bearer\s+/i, "");
-```ts
-server.route([
- // Endpoint to login or register and to send the short-lived token
- {
- method: 'POST',
- path: '/login',
- handler: loginHandler,
- options: {
- auth: false,
- validate: {
- payload: Joi.object({
- email: Joi.string()
- .email()
- .required(),
- }),
- },
- },
- },
-])
-```
-> **Note:** `options.auth` is set to false so that the endpoint will remain open once you set the default authentication strategy which will by default require authentication for all routes that don't disable it explicitly.
+ let payload;
+ try {
+ payload = await verify(token, JWT_SECRET, JWT_ALGORITHM);
+ } catch {
+ return c.json({ error: "Invalid token" }, 401);
+ }
-Outside the register function of the plugin, add the following:
+ const tokenId = payload.tokenId;
+ if (typeof tokenId !== "number") {
+ return c.json({ error: "Invalid token" }, 401);
+ }
-```ts
-const EMAIL_TOKEN_EXPIRATION_MINUTES = 10
+ const prisma = c.get("prisma");
+ const fetchedToken = await prisma.token.findUnique({
+ where: { id: tokenId },
+ include: { user: true },
+ });
-interface LoginInput {
- email: string
-}
+ if (!fetchedToken || !fetchedToken.valid) {
+ return c.json({ error: "Invalid token" }, 401);
+ }
-async function loginHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
- // 👇 get prisma and the sendEmailToken from shared application state
- const { prisma, sendEmailToken } = request.server.app
- // 👇 get the email from the request payload
- const { email } = request.payload as LoginInput
- // 👇 generate an alphanumeric token
- const emailToken = generateEmailToken()
- // 👇 create a date object for the email token expiration
- const tokenExpiration = add(new Date(), {
- minutes: EMAIL_TOKEN_EXPIRATION_MINUTES,
- })
+ if (fetchedToken.expiration < new Date()) {
+ return c.json({ error: "Token expired" }, 401);
+ }
- try {
- // 👇 create a short lived token and update user or create if they don't exist
- const createdToken = await prisma.token.create({
- data: {
- emailToken,
- type: TokenType.EMAIL,
- expiration: tokenExpiration,
- user: {
- connectOrCreate: {
- create: {
- email,
- },
- where: {
- email,
- },
- },
- },
- },
- })
+ const teacherOf = await prisma.courseEnrollment.findMany({
+ where: { userId: fetchedToken.userId, role: "TEACHER" },
+ select: { courseId: true },
+ });
- // 👇 send the email token
- await sendEmailToken(email, emailToken)
- return h.response().code(200)
- } catch (error) {
- return Boom.badImplementation(error.message)
- }
-}
+ c.set("credentials", {
+ tokenId,
+ userId: fetchedToken.userId,
+ isAdmin: fetchedToken.user.isAdmin,
+ teacherOf: teacherOf.map(({ courseId }) => courseId),
+ });
-// Generate a random 8 digit number as the email token
-function generateEmailToken(): string {
- return Math.floor(10000000 + Math.random() * 90000000).toString()
+ await next();
}
```
-`loginHandler` does the following:
-- The email is taken from the request payload
-- A token is generated and then saved to the database
-- With `connectOrCreate`, if a user with the email address in the payload doesn't exist, it's created. Otherwise, a relation is created to the existing user.
-- The token is sent to the email address in the payload (or logged to the console if `SENDGRID_API_KEY` isn't set)
+The `authMiddleware` is the equivalent of Hapi's strategy `validate` function. It verifies the JWT, looks up the referenced token to confirm it's still valid and unexpired (the stateful part), fetches the courses the user teaches (for authorization later), and stores everything on the context with `c.set("credentials", ...)`. Downstream handlers read it with `c.get("credentials")`.
-Finally, register the plugin in `server.ts`:
+For the credentials to be typed, extend the `AppEnv` you defined in `src/lib/prisma.ts` in part 2:
```ts
-import hapiAuthJWT from 'hapi-auth-jwt2'
-import authPlugin from './plugins/auth'
-
-await server.register([
- // ... existing plugins
- hapiAuthJWT,
- authPlugin,
-])
-```
-**Checkpoint:**
+export type AuthCredentials = {
+ tokenId: number;
+ userId: number;
+ isAdmin: boolean;
+ teacherOf: number[];
+};
-1. Start the server with `npm run dev`
-1. Make a POST call to the `/login` endpoint with curl: `curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login`. You should see a token logged from the backend: `email token for test@test.io: 27948216`
-
-### Defining the authentication endpoint
+export type AppEnv = {
+ Variables: {
+ prisma: PrismaClient;
+ credentials: AuthCredentials;
+ };
+};
+```
-At this point, the backend can create users, generate email tokens, and send them via email. However, the tokens generated are still not functional. You will now implement the second step of authentication by creating the `/authenticate` endpoint, verifying the email token against the database, and returning the user a long-lived JWT authentication token in the `authorization` header.
+### Defining the login and authenticate routes
-Begin by adding the following route declaration to the `authPlugin`:
+Create `src/routes/auth.ts` with the two open endpoints that drive the flow:
```ts
-server.route({
- method: 'POST',
- path: '/authenticate',
- handler: authenticateHandler,
- options: {
- auth: false,
- validate: {
- payload: Joi.object({
- email: Joi.string()
- .email()
- .required(),
- emailToken: Joi.string().required(),
- }),
+import { Hono } from "hono";
+import { zValidator } from "@hono/zod-validator";
+import { z } from "zod";
+import type { AppEnv } from "../lib/prisma";
+import { sendEmailToken } from "../lib/email";
+import {
+ authTokenExpiration,
+ emailTokenExpiration,
+ generateAuthToken,
+ generateEmailToken,
+} from "../lib/auth";
+
+const auth = new Hono();
+
+// Step 1: log in or sign up. Creates the user if new, stores a short-lived
+// email token, and sends it to the user's email.
+auth.post("/login", zValidator("json", z.object({ email: z.email() })), async (c) => {
+ const prisma = c.get("prisma");
+ const { email } = c.req.valid("json");
+
+ const emailToken = generateEmailToken();
+
+ await prisma.token.create({
+ data: {
+ emailToken,
+ type: "EMAIL",
+ expiration: emailTokenExpiration(),
+ user: {
+ connectOrCreate: {
+ where: { email },
+ create: { email },
+ },
+ },
},
- },
-})
-```
-The route requires both the `email` and the `emailToken`. Since only the legitimate user attempting to login will know both, guessing both the `email` and `emailToken` becomes more difficult, thereby reducing the risk of brute force attacks which guess the eight-digit number.
+ });
-Next, add the following to `auth.ts`:
-
-```ts
-// Load the JWT secret from environment variables or default
-const JWT_SECRET = process.env.JWT_SECRET || 'SUPER_SECRET_JWT_SECRET'
+ await sendEmailToken(email, emailToken);
-const JWT_ALGORITHM = 'HS256'
-
-const AUTHENTICATION_TOKEN_EXPIRATION_HOURS = 12
-
-interface AuthenticateInput {
- email: string
- emailToken: string
-}
+ return c.body(null, 200);
+});
-async function authenticateHandler(
- request: Hapi.Request,
- h: Hapi.ResponseToolkit,
-) {
- // 👇 get prisma from shared application state
- const { prisma } = request.server.app
- // 👇 get the email and emailToken from the request payload
- const { email, emailToken } = request.payload as AuthenticateInput
+// Step 2: exchange the email token for a long-lived JWT. Validates the email
+// token, mints an API token in the database, invalidates the email token, and
+// returns the signed JWT in the Authorization header.
+auth.post(
+ "/authenticate",
+ zValidator("json", z.object({ email: z.email(), emailToken: z.string() })),
+ async (c) => {
+ const prisma = c.get("prisma");
+ const { email, emailToken } = c.req.valid("json");
- try {
- // Get short lived email token
- const fetchedEmailToken = await prisma.token.findUnique({
- where: {
- emailToken: emailToken,
- },
- include: {
- user: true,
- },
- })
+ const fetchedToken = await prisma.token.findUnique({
+ where: { emailToken },
+ include: { user: true },
+ });
- if (!fetchedEmailToken?.valid) {
- // If the token doesn't exist or is not valid, return 401 unauthorized
- return Boom.unauthorized()
+ if (!fetchedToken || !fetchedToken.valid) {
+ return c.json({ error: "Unauthorized" }, 401);
}
- if (fetchedEmailToken.expiration < new Date()) {
- // If the token has expired, return 401 unauthorized
- return Boom.unauthorized('Token expired')
+ if (fetchedToken.expiration < new Date()) {
+ return c.json({ error: "Token expired" }, 401);
}
- // If token matches the user email passed in the payload, generate long lived API token
- if (fetchedEmailToken?.user?.email === email) {
- const tokenExpiration = add(new Date(), {
- hours: AUTHENTICATION_TOKEN_EXPIRATION_HOURS,
- })
- // Persist token in DB so it's stateful
- const createdToken = await prisma.token.create({
- data: {
- type: TokenType.API,
- expiration: tokenExpiration,
- user: {
- connect: {
- email,
- },
- },
- },
- })
-
- // Invalidate the email token after it's been used
- await prisma.token.update({
- where: {
- id: fetchedEmailToken.id,
- },
- data: {
- valid: false,
- },
- })
-
- const authToken = generateAuthToken(createdToken.id)
- return h.response().code(200).header('Authorization', authToken)
- } else {
- return Boom.unauthorized()
+ if (fetchedToken.user.email !== email) {
+ return c.json({ error: "Unauthorized" }, 401);
}
- } catch (error) {
- return Boom.badImplementation(error.message)
- }
-}
-
-// Generate a signed JWT token with the tokenId in the payload
-function generateAuthToken(tokenId: number): string {
- const jwtPayload = { tokenId }
-
- return jwt.sign(jwtPayload, JWT_SECRET, {
- algorithm: JWT_ALGORITHM,
- noTimestamp: true,
- })
-}
-```
-> **Note:** The environment variable `JWT_SECRET` can be generated by running the following command: `node -e "console.log(require('crypto').randomBytes(256).toString('base64'));"`. This should always be set in production environments.
-The handler fetches the email token from the database, ensures it's valid, creates a new API token in the database, generates a JWT token (with a reference to the token in the database), invalidates the email token, and returns the token in the `Authorization` header.
+ const createdToken = await prisma.token.create({
+ data: {
+ type: "API",
+ expiration: authTokenExpiration(),
+ user: { connect: { email } },
+ },
+ });
-**Checkpoint:**
+ await prisma.token.update({
+ where: { id: fetchedToken.id },
+ data: { valid: false },
+ });
-1. Start the server with `npm run dev`
-2. Make a POST call to the `/login` endpoint with curl: `curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login` you should see a token logged from the backend: `email token for test@test.io: 13080740`.
-3. Take that token and call the `/authenticate` endpoint with curl: `curl -v --header "Content-Type: application/json" --request POST --data '{"email":"hello@prisma.io", "emailToken": "13080740"}' localhost:3000/authenticate`.
-4. The response should have the `200` status and include an `Authorization` header which looks similar to this: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg`
+ const authToken = await generateAuthToken(createdToken.id);
+ c.header("Authorization", authToken);
+ return c.json({ authToken }, 200);
+ },
+);
-### Defining the authentication strategy
+export default auth;
+```
-The authentication strategy will define how the Hapi will verify requests to endpoints that require authentication. In this step, you will define the logic for verifying requests with a JWT token, by fetching the information about the user from the database using the `tokenId` in the JWT token.
+`/login` uses `connectOrCreate` to create the user if the email is new or connect to the existing one. `/authenticate` verifies the email token, creates a long-lived API token row, invalidates the email token so it can't be reused, signs a JWT referencing the new token, and returns it in the `Authorization` header.
-To define the authentication strategy, add the following to `auth.ts`:
+### Wiring the middleware into the app
-```ts
-// This strategy will be used across the application to secure routes
-export const API_AUTH_STATEGY = 'API'
-```
-Inside the `authPlugin.register` function add the following:
+Update `src/app.ts` to mount the open routes, then apply `authMiddleware` so every route registered after it requires a valid JWT:
```ts
-// Define the authentication strategy which uses the `jwt` authentication scheme
-server.auth.strategy(API_AUTH_STATEGY, 'jwt', {
- key: JWT_SECRET,
- verifyOptions: { algorithms: [JWT_ALGORITHM] },
- validate: validateAPIToken,
-})
-
-// Set the default authentication strategy for API routes, unless explicitly disabled
-server.auth.default(API_AUTH_STATEGY)
-```
-Lastly, add the `validateAPIToken` function:
+import { Hono } from "hono";
+import { withPrisma, type AppEnv } from "./lib/prisma";
+import { authMiddleware } from "./lib/auth";
+import status from "./routes/status";
+import auth from "./routes/auth";
+import users from "./routes/users";
-```ts
-const apiTokenSchema = Joi.object({
- tokenId: Joi.number().integer().required(),
-})
-
-// Function will be called on every request using the auth strategy
-const validateAPIToken = async (
- decoded: APITokenPayload,
- request: Hapi.Request,
- h: Hapi.ResponseToolkit,
-) => {
- const { prisma } = request.server.app
- const { tokenId } = decoded
- // Validate the token payload adheres to the schema
- const { error } = apiTokenSchema.validate(decoded)
-
- if (error) {
- request.log(['error', 'auth'], `API token error: ${error.message}`)
- return { isValid: false }
- }
+const app = new Hono();
- try {
- // Fetch the token from DB to verify it's valid
- const fetchedToken = await prisma.token.findUnique({
- where: {
- id: tokenId,
- },
- include: {
- user: true,
- },
- })
+app.use("*", withPrisma);
- // Check if token could be found in database and is valid
- if (!fetchedToken || !fetchedToken?.valid) {
- return { isValid: false, errorMessage: 'Invalid Token' }
- }
+// Open routes (no authentication required).
+app.route("/", status);
+app.route("/", auth);
- // Check token expiration
- if (fetchedToken.expiration < new Date()) {
- return { isValid: false, errorMessage: 'Token expired' }
- }
+// Everything registered below requires a valid JWT.
+app.use("*", authMiddleware);
+app.route("/", users);
- // Get all the courses that the user is the teacher of
- const teacherOf = await prisma.courseEnrollment.findMany({
- where: {
- userId: fetchedToken.userId,
- role: UserRole.TEACHER,
- },
- select: {
- courseId: true,
- },
- })
-
- // The token is valid. Make the `userId`, `isAdmin`, and `teacherOf` to `credentials` which is available in route handlers via `request.auth.credentials`
- return {
- isValid: true,
- credentials: {
- tokenId: decoded.tokenId,
- userId: fetchedToken.userId,
- isAdmin: fetchedToken.user.isAdmin,
- // convert teacherOf from an array of objects to an array of numbers
- teacherOf: teacherOf.map(({ courseId }) => courseId),
- },
- }
- } catch (error) {
- request.log(['error', 'auth', 'db'], error)
- return { isValid: false }
- }
-}
+export default app;
```
-The `validateAPIToken` function will get called before every route that uses the `API_AUTH_STATEGY` (which you've set as the default in the previous step).
-The purpose of the `validateAPIToken` function is to **determine whether to allow the request to proceed**. This is done with the return object, which contains `isValid` and `credentials`:
+Because Hono runs middleware and routes in registration order, the open routes (`status`, `auth`) are matched before `authMiddleware` is added, while `users` is registered after it and is therefore protected.
-- `isValid`: determines whether the token was successfully verified.
-- `credentials` can be used to pass information about the user to the request object. The object passed to `credentials` is accessible within the route handler via `request.auth.credentials`.
+**Checkpoint:** Start the server with `npm run dev`, then run the login flow with curl:
-In this case, we determine that if the token exists in the database, it is valid and hasn't expired.
-If so, we fetch the courses the user is the teacher of (which will be used to implement authorization) and pass that along with the `tokenId`, `userId`, and `isAdmin` to the credentials object.
+```sh
+curl -X POST -H "Content-Type: application/json" -d '{"email":"grace@prisma.io"}' localhost:3000/login
+```
-Most endpoints require authentication (because of the default auth strategy), but there are still no authorization rules. That means that to access the `GET /courses` endpoint, you now need to have a valid JWT token in the `Authorization` header.
+You should see the token logged by the backend, for example: `email token for grace@prisma.io: 89148041`. Exchange it for a JWT:
-**Checkpoint:**
+```sh
+curl -i -X POST -H "Content-Type: application/json" \
+ -d '{"email":"grace@prisma.io", "emailToken":"89148041"}' \
+ localhost:3000/authenticate
+```
-1. Start the server with `npm run dev`
-1. Make a GET call to the `/courses` endpoint with curl: `curl -v localhost:3000/courses`.
- You should get a 401 status code with the following response: `{"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}`.
-1. Make another call with the `Authorization` header with the token from the last checkpoint as follows: `curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/courses` and the request should succeed
+The response has a `200` status and an `Authorization` header with the JWT. A request to a protected route without that token returns `401`:
-Congratulations, you have successfully implemented email-based passwordless authentication and secured the endpoints. Next, you will define authorization rules.
+```sh
+curl -i localhost:3000/profile # 401 Missing authentication
+curl -i -H "Authorization: Bearer " localhost:3000/profile # 200
+```
## Adding authorization
-The authorization model of the backend will define what a user is allowed to do. In other words, which entities are they allowed to perform operations on.
-
-The main properties that will grant users permissions are:
+The authorization model defines what a user is allowed to do. Two properties grant permissions:
-- Is the user an admin (as denoted by the `isAdmin` fields in the user model)? If so, they will be allowed to perform every operation.
-- Is the user a teacher of a course? If so, the user will be allowed to perform CRUD operations on all course-specific resources such as tests, test results, and enrollment.
+- **Is the user an admin** (the `isAdmin` field)? If so, they can perform every operation.
+- **Is the user a teacher of a course?** If so, they can perform operations on that course's resources (tests, results, enrollment).
-If a user is not an admin or a teacher of a course, they should still be able to create new courses, enroll as a student in existing courses, get their test results, and fetch and update their user profile.
+Otherwise, a user can still create courses, enroll as a student, read their own test results, and read and update their own profile.
-> **Note:** This approach mixes two authorization approaches, namely role-based and resource-based authorization. Deriving permissions from the course enrollment is a form of resource-based authorization. That means that actions are authorized based on a specific resource, i.e. enrollment in a course as a teacher allows the user to create related tests and submit test results. On the other hand, authorizing actions to admin users (with `isAdmin` set to true) is a form of role-based authorization where the user has the "admin" role.
+> **Note:** This mixes two approaches. Deriving permissions from course enrollment is _resource-based_ authorization; granting admins full access via the `isAdmin` flag is _role-based_ authorization.
### Authorization rules for the endpoints
-To implement the proposed authorization rules, we will first revisit the list of endpoints with the proposed authorization rules:
-
-| HTTP Method | Route | Description | Authorization rule |
-| ----------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------- |
-| `POST` | `/login` | Start login/signup and send email token | Open |
-| `POST` | `/authenticate` | Authenticate user and create JWT token | Open (requires email token) |
-| `GET` | `/profile` | Get the authenticated user profile | Any authenticated user |
-| `POST` | `/users` | Create a user | Only Admin |
-| `GET` | `/users/{userId}` | Get a user | Only Admin or authenticated user |
-| `PUT` | `/users/{userId}` | Update a user | Only Admin or authenticated user |
-| `DELETE` | `/users/{userId}` | Delete a user | Only Admin or authenticated user |
-| `GET` | `/users` | Get users | Only Admin |
-| `GET` | `/users/{userId}/courses` | Get a user's enrollement incourses | Only Admin or authenticated user |
-| `POST` | `/users/{userId}/courses` | Enroll a user to a course (as student or teacher) | Only Admin or authenticated user |
-| `DELETE` | `/users/{userId}/courses/{courseId}` | Delete a user's enrollment to a course | Only Admin or authenticated user |
-| `POST` | `/courses` | Create a course | Any authenticated user |
-| `GET` | `/courses` | Get courses | Any authenticated user |
-| `GET` | `/courses/{courseId}` | Get a course | Any authenticated user |
-| `PUT` | `/courses/{courseId}` | Update a course | Only admin or teacher of course |
-| `DELETE` | `/courses/{courseId}` | Delete a course | Only admin or teacher of course |
-| `POST` | `/courses/{courseId}/tests` | Create a test for a course | Only admin or teacher of course |
-| `GET` | `/courses/tests/{testId}` | Get a test | Any authenticated user |
-| `PUT` | `/courses/tests/{testId}` | Update a test | Only admin or teacher of course |
-| `DELETE` | `/courses/tests/{testId}` | Delete a test | Only admin or teacher of course |
-| `GET` | `/users/{userId}/test-results` | Get a user's test results | Only Admin or authenticated user |
-| `POST` | `/courses/tests/{testId}/test-results` | Create test result for a test associated with a user | Only admin or teacher of course |
-| `GET` | `/courses/tests/{testId}/test-results` | Get multiple test results for a test | Only admin or teacher of course |
-| `PUT` | `/courses/tests/test-results/{testResultId}` | Update a test result (associated with a user and a test) | Only admin or grader of test |
-| `DELETE` | `/courses/tests/test-results/{testResultId}` | Delete a test result | Only admin or grader of test |
-
-> **Note:** The paths containing a parameter enclosed in `{}`, e.g. `{userId}` represent a variable that is interpolated in the URL, e.g. in `www.myapi.com/users/13` the `userId` is `13`.
-
-### authorization with Hapi
-
-Hapi routes have the notion of `pre` functions that allow breaking the handler logic into smaller and reusable functions.
-`pre` functions get called before the handler and allow taking over the response and returning an unauthorized error.
-This is useful in the context of authorization because many of the authorization rules proposed in the table above will be the same for multiple routes/endpoints.
-For example, checking whether the user is an admin will be the same for both the `POST /users` and the `GET /users` routes.
-That allows you to reuse a single `isAdmin` pre-function and assign to the two endpoints.
-
-### Adding authorization to the users endpoints
-
-In this part, you will define `pre` functions to implement the different authorization rules.
-You will start with the three `/users/{userId}` endpoints (`GET`, `POST`, and `DELETE`) which should be authorized if the user making the request is an admin or if the user is requesting his own `userId`.
-
-> **Note:** Hapi also provides a way to implement role-based authentication declaratively with [scopes](https://hapi.dev/api/?v=20.0.0#-routeoptionsauthaccessscope). However, the proposed resource-based authorization approach –where the user's permissions depend on the specific resource requested– requires more granular control that cannot be done with scopes, so `pre` functions are used.
-
-To add a pre-function to verify the authorization rule in the `GET /users/{userId}` route, declare the following function in `src/plugins/user.ts`:
+| HTTP Method | Route | Authorization rule |
+| ----------- | -------------------------------------------- | -------------------------------- |
+| `POST` | `/login` | Open |
+| `POST` | `/authenticate` | Open (requires email token) |
+| `GET` | `/profile` | Any authenticated user |
+| `POST` | `/users` | Only admin |
+| `GET` | `/users/{userId}` | Only admin or the user |
+| `PUT` | `/users/{userId}` | Only admin or the user |
+| `DELETE` | `/users/{userId}` | Only admin or the user |
+| `PUT` | `/courses/{courseId}` | Only admin or teacher of course |
+| `DELETE` | `/courses/{courseId}` | Only admin or teacher of course |
+| `POST` | `/courses/{courseId}/tests` | Only admin or teacher of course |
-```ts
-// Pre-function to check if the authenticated user matches the requested user
-export async function isRequestedUserOrAdmin(request: Hapi.Request, h: Hapi.ResponseToolkit) {
- // 👇 userId and isAdmin are populated by the `validateAPIToken` function
- const { userId, isAdmin } = request.auth.credentials
+### Authorization with Hono middleware
+
+In Hapi, authorization used route `pre` functions. In Hono, an authorization rule is just another middleware that runs after `authMiddleware` and either calls `next()` or returns a `403`. Because the rules are reusable, define them once in `src/lib/auth.ts`:
+```ts
+// Allow admins, or the user acting on their own account.
+export async function isRequestedUserOrAdmin(c: Context, next: Next) {
+ const { userId, isAdmin } = c.get("credentials");
if (isAdmin) {
- // If the user is an admin allow
- return h.continue
+ return next();
}
-
- const requestedUserId = parseInt(request.params.userId, 10)
-
- // 👇 Check that the requested userId matches the authenticated userId
+ const requestedUserId = Number(c.req.param("userId"));
if (requestedUserId === userId) {
- return h.continue
+ return next();
}
-
- // The authenticated user is not authorized
- throw Boom.forbidden()
-}
-```
-Then add the pre option to the route definition in `src/plugins/user.ts` as follows:
-
-```json
-diff
-{
- method: 'GET',
- path: '/users/{userId}',
- handler: getUserHandler,
- options: {
-+ pre: [isRequestedUserOrAdmin],
-+ auth: {
-+ mode: 'required',
-+ strategy: API_AUTH_STATEGY,
-+ },
- validate: {
- params: Joi.object({
- userId: Joi.number().integer(),
- }),
- },
- },
+ return c.json({ error: "Forbidden" }, 403);
}
-```
-The pre-function will now be called before `getUserHandler` and only authorize access to admins or to users requesting their own userId.
-
-> **Note:** In the previous part, you've defined the default authentication strategy, so defining `options.auth` is not strictly required. But it's good practice to define the authentication requirements for every route explicitly.
-
-**Checkpoint:** To verify the authorization logic has been correctly implemented, you will create a test user and test admin and call the `/users/{userId}` endpoint:
-
-1. Start the server with `npm run dev`
-1. Run the `seed-users` script to create a test user and test admin: `npm run seed-users`. You should get a result similar to this:
-```
- Created test user id: 1 | email: test@prisma.io
- Created test admin id: 2 | email: test-admin@prisma.io
- ```
-1. Login as `test@prisma.io` by making a call to the `POST /login` endpoint as follows:
-```
- curl --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io"}' localhost:3000/login
- ```
-1. Take the logged token and call the `/authenticate` endpoint with curl:
-```
- curl -v --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io", "emailToken": "TOKEN_FROM_CONSOLE"}' localhost:3000/authenticate
- ```
-1. The response should have the `200` status and include an `Authorization` header which looks similar to this: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg`
-1. Make a GET call to `/users/1` (where the number is the test user created in the first step of the checkpoint) with the `Authorization` header containing the token from the last checkpoint as follows: `curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/1` and the request should succeed and you should see the user profile.
-1. Make another GET call to `/users/2` with the same authorization header: `curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/2`. This one should fail with a 403 forbidden error.
-
-If all steps succeeded, the `isRequestedUserOrAdmin` pre-function correctly authorizes users to access their own user profile. To test the admin functionality, repeat from the third step but log in as the test admin with the email `test-admin@prisma.io`. The admin should be able to get both user profiles.
-
-### Moving the authorization pre-function to a separate module
-
-So far you've defined the `isRequestedUserOrAdmin` authorization pre-function and added it to the `GET /users/{userId}` route. To make use of this in different routes, move the function from `src/plugins/users.ts` to a separate module: `src/auth-helpers.ts`. This module will allow you to keep the authorization logic organized in a single place and reuse it for routes defined in different plugins, e.g. the `GET /users/{userId}/courses` route in `user-enrollment.ts`.
-
-Once you've moved the `isRequestedUserOrAdmin` function into `auth-helpers.ts`, add it as a pre-function to the following routes, which have the same authorization logic:
-
-| Module | Route |
-| --------------------------------- | ---------------------------------- |
-| `src/plugins/users.ts` | `DELETE /users/{userId}` |
-| `src/plugins/users.ts` | `PUT /users/{userId}` |
-| `src/plugins/users-enrollment.ts` | `GET /users/{userId}/courses` |
-| `src/plugins/users-enrollment.ts` | `POST /users/{userId}/courses` |
-| `src/plugins/users-enrollment.ts` | `DELETE /users/{userId}/courses` |
-| `src/plugins/test-results.ts` | `GET /users/{userId}/test-results` |
-
-### Adding authorization to course specific endpoints
-
-Teachers should be able to update courses and create tests for courses they are teachers of and admins. In this step, you will create another pre-function to verify that.
-
-Define the following pre-function in `auth-helpers.ts`:
-
-```ts
-export async function isTeacherOfCourseOrAdmin(
- request: Hapi.Request,
- h: Hapi.ResponseToolkit,
-) {
- // 👇 isAdmin and teacherOf are populated by the `validateAPIToken` function
- const { isAdmin, teacherOf } = request.auth.credentials
+// Allow admins, or a teacher of the requested course.
+export async function isTeacherOfCourseOrAdmin(c: Context, next: Next) {
+ const { isAdmin, teacherOf } = c.get("credentials");
if (isAdmin) {
- // If the user is an admin allow
- return h.continue
+ return next();
}
+ const courseId = Number(c.req.param("courseId"));
+ if (teacherOf.includes(courseId)) {
+ return next();
+ }
+ return c.json({ error: "Forbidden" }, 403);
+}
- const courseId = parseInt(request.params.courseId, 10)
-
- // Verify that the authenticated user is a teacher of the requested course
- if (teacherOf?.includes(courseId)) {
- return h.continue
+// Allow admins only.
+export async function requireAdmin(c: Context, next: Next) {
+ const { isAdmin } = c.get("credentials");
+ if (!isAdmin) {
+ return c.json({ error: "Forbidden" }, 403);
}
- // If the user is not a teacher of the course, deny access
- throw Boom.forbidden()
+ return next();
}
```
-The pre-function uses the `teacherOf` array that is fetched in `validateAPIToken` to check if the user is a teacher of the requested course.
-Add the `isTeacherOfCourseOrAdmin` as a pre-function to the following routes:
+`isRequestedUserOrAdmin` and `isTeacherOfCourseOrAdmin` use the `credentials` that `authMiddleware` set, so they don't touch the database. This mirrors how the Hapi `pre` functions read `request.auth.credentials`.
-| Module | Route |
-| ------------------------ | -------------------------------- |
-| `src/plugins/courses.ts` | `PUT /courses/{courseId}` |
-| `src/plugins/courses.ts` | `DELETE /courses/{courseId}` |
-| `src/plugins/tests.ts` | `POST /courses/{courseId}/tests` |
+### Applying the rules to the user routes
-Update the routes from the table by adding the following `options.pre`:
+Update `src/routes/users.ts` to protect the endpoints. Add a `/profile` route for the authenticated user, require admin for creating users, and require admin-or-self for the `/users/{userId}` routes:
```ts
-options: {
- pre: [isTeacherOfCourseOrAdmin],
- // ... other route options
-}
-```
-You have now implemented two different authorization rules and added then as a pre-function to ten different routes in the backend.
-
-## Updating the tests
-
-After implementing authentication and authorization in the REST API, the tests will fail because the routes now require the user to be authenticated. In this step, you will adapt the tests to consider authentication.
+import { Hono } from "hono";
+import { zValidator } from "@hono/zod-validator";
+import { z } from "zod";
+import type { AppEnv } from "../lib/prisma";
+import { isRequestedUserOrAdmin, requireAdmin } from "../lib/auth";
+
+// ...userSchema, updateUserSchema, and userIdParam from part 2...
+
+const users = new Hono();
+
+// Any authenticated user can read their own profile.
+users.get("/profile", async (c) => {
+ const prisma = c.get("prisma");
+ const { userId } = c.get("credentials");
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ return c.json(user, 200);
+});
+
+// Only admins can create users directly.
+users.post("/users", requireAdmin, zValidator("json", userSchema), async (c) => {
+ const prisma = c.get("prisma");
+ const data = c.req.valid("json");
+ const createdUser = await prisma.user.create({ data, select: { id: true } });
+ return c.json(createdUser, 201);
+});
+
+users.get(
+ "/users/:userId",
+ zValidator("param", userIdParam),
+ isRequestedUserOrAdmin,
+ async (c) => {
+ const prisma = c.get("prisma");
+ const { userId } = c.req.valid("param");
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user) {
+ return c.body(null, 404);
+ }
+ return c.json(user, 200);
+ },
+);
-For example, the `GET /users/{userId}` endpoint has the following test:
+// The PUT and DELETE /users/:userId routes take the same isRequestedUserOrAdmin
+// middleware. The course and test routes would use isTeacherOfCourseOrAdmin.
-```ts
-test('get user returns user', async () => {
- const response = await server.inject({
- method: 'GET',
- url: `/users/${userId}`,
- })
- expect(response.statusCode).toEqual(200)
- const user = JSON.parse(response.payload)
-
- expect(user.id).toBe(userId)
-})
+export default users;
```
-If you run this test now with `npm run test -- -t="get user returns user"` the test will fail. This is because the test when the request reaches the endpoint it does not meet its authentication requirements. With Hapi's [`server.inject`](https://hapi.dev/api?v=19.2.0#-await-serverinjectoptions) –that simulates an HTTP request to the server–, you can add an `auth` object with information about the authenticated user. The `auth` object sets the credentials object as they would in the `validateAPIToken` function in `src/plugins/auth.ts`, for example:
-```ts
-test('get user returns user', async () => {
- const response = await server.inject({
- method: 'GET',
- url: `/users/${testUser.id}`,
- auth: {
- strategy: API_AUTH_STATEGY,
- credentials: {
- userId: testUser.id,
- tokenId: // TODO: create the token and pass it here
- isAdmin: // TODO: set this based on the test user
- teacherOf: // TODO: set this based on the test user,
- },
- },
- })
- expect(response.statusCode).toEqual(200)
- const user = JSON.parse(response.payload)
+Each route lists its middleware before the handler, so the chain is: `authMiddleware` (from `app.ts`) sets the credentials, then the authorization middleware allows or denies, then the handler runs. Adding a rule to a route is a one-line change, and the same function is reused across routes, just like the Hapi `pre` functions were.
- expect(user.id).toBe(testUserCredentials.userId)
-})
-```
-The `credentials` object passed matches the `AuthCredentials` interface defined in `src/plugins/auth.ts`:
+## Updating the tests
-```ts
-interface AuthCredentials {
- userId: number
- tokenId: number
- isAdmin: boolean
- teacherOf: number[]
-}
-```
-> **Note:** An interface in TypeScript is very similar to a type with some subtle differences. To learn more, checkout the [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/advanced-types.html#interfaces-vs-type-aliases).
+After adding authentication, the part 2 tests would fail because the user routes now require a valid JWT. In Hapi you could inject fake credentials into `server.inject`. With Hono there's no special hook, but you don't need one: create a real user with an API token in the database and send a real signed JWT in the `Authorization` header. It's closer to what actually happens in production.
-For the test to pass, you will create a user directly with Prisma in the test and construct the `AuthCredentials` object as follows:
+Add a helper to `tests/users.test.ts` that creates a user with a token and returns a JWT for it, mirroring what `/authenticate` produces:
```ts
-test('get user returns user', async () => {
- const testUser = await server.app.prisma.user.create({
+import { generateAuthToken } from "../src/lib/auth";
+
+async function createUserWithToken(isAdmin = false) {
+ const user = await prisma.user.create({
data: {
- email: `test-${Date.now()}@test.com`,
- isAdmin: false,
+ email: `test-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`,
+ isAdmin,
tokens: {
create: {
- expiration: add(new Date(), { days: 7 }),
- type: TokenType.API,
+ type: "API",
+ expiration: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
},
},
- include: {
- tokens: true,
- },
- })
-
- const testUserCredentials = {
- userId: testUser.id,
- tokenId: testUser.tokens[0].id,
- isAdmin: testUser.isAdmin,
- teacherOf: [], // empty array because no courses were created for the user
- }
-
- const response = await server.inject({
- method: 'GET',
- url: `/users/${testUserCredentials.userId}`,
- auth: {
- strategy: API_AUTH_STATEGY,
- credentials: testUserCredentials,
- },
- })
- expect(response.statusCode).toEqual(200)
- const user = JSON.parse(response.payload)
+ include: { tokens: true },
+ });
+ const authToken = await generateAuthToken(user.tokens[0].id);
+ return { user, authToken };
+}
- expect(user.id).toBe(testUserCredentials.userId)
-})
+const authHeader = (token: string) => ({ Authorization: `Bearer ${token}` });
```
-**Checkpoint:** Run `npm run test -- -t="get user returns user"` to verify that the test passes.
-At this point, you've fixed one test, but what about the others? Since creating the credentials object will be required in most of the tests, you can abstract it into a separate `test-helpers.ts` module:
+With that helper, the authorization tests read naturally. For example, a non-admin can read their own record but not someone else's:
```ts
-// Helper function to create a test user and return the credentials object the same way that the auth plugin does
-export const createUserCredentials = async (
- prisma: PrismaClient,
- isAdmin: boolean,
-): Promise => {
- const testUser = await prisma.user.create({
- data: {
- email: `test-${Date.now()}@test.com`,
- isAdmin,
- tokens: {
- create: {
- expiration: add(new Date(), { days: 7 }),
- type: TokenType.API,
- },
- },
- },
- include: {
- tokens: true,
- courses: {
- where: {
- role: UserRole.TEACHER,
- },
- select: {
- courseId: true,
- },
- },
- },
- })
+test("a user can GET their own record", async () => {
+ const { user, authToken } = await createUserWithToken();
+ const res = await app.request(`/users/${user.id}`, { headers: authHeader(authToken) });
+ expect(res.status).toBe(200);
+ expect((await res.json()).id).toBe(user.id);
+});
- return {
- userId: testUser.id,
- tokenId: testUser.tokens[0].id,
- isAdmin: testUser.isAdmin,
- teacherOf: testUser.courses?.map(({ courseId }) => courseId),
- }
-}
+test("a non-admin cannot GET another user's record", async () => {
+ const { authToken } = await createUserWithToken();
+ const other = await createUserWithToken();
+ const res = await app.request(`/users/${other.user.id}`, {
+ headers: authHeader(authToken),
+ });
+ expect(res.status).toBe(403);
+});
```
-As a next step, write a test that that verifies the authorization rule allowing admins to fetch different user accounts with the `GET /users/{userId}` endpoint.
-
-## Summary and next steps
-Congratulations on making it this far. The article covered many concepts, starting with authentication and authorization concepts to implementing email-based passwordless authentication with Prisma, Hapi, and JWT. Lastly, you implemented authorization rules with Hapi's pre-functions.
-You also created an email plugin to provide the backend with the ability to send emails with SendGrid's API.
+You can test the full login flow the same way: `POST /login`, read the email token from the database (it's the same value the console logs), then `POST /authenticate` and assert the `Authorization` header is present.
-The auth plugin encapsulated the two routes for the authentication flow and used the `jwt` authentication scheme to define the authentication strategy. In the authentication strategy's validate function, you checked tokens against the database and populated the credentials object with information relevant for the authorization rules.
+**Checkpoint:** Run `npm test`. With the auth and authorization tests in place, you should see all tests pass:
-You also carried out a database migration and introduced a new `Token` table with an _n-1_ relation to the `User` table with [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate).
+```sh
+ ✓ tests/status.test.ts > Status route > GET / returns 200 and { up: true }
+ ✓ tests/users.test.ts > User routes > protected route returns 401 without a token
+ ✓ tests/users.test.ts > User routes > login + authenticate returns a JWT in the Authorization header
+ ✓ tests/users.test.ts > User routes > GET /profile returns the authenticated user
+ ✓ tests/users.test.ts > User routes > POST /users is forbidden for non-admins
+ ✓ tests/users.test.ts > User routes > POST /users creates a user for admins
+ ✓ tests/users.test.ts > User routes > a user can GET their own record
+ ✓ tests/users.test.ts > User routes > a non-admin cannot GET another user's record
+ ✓ tests/users.test.ts > User routes > an admin can GET another user's record
+ ✓ tests/users.test.ts > User routes > a user can update their own record
+ ✓ tests/users.test.ts > User routes > a user can delete their own record
+
+ Test Files 2 passed (2)
+ Tests 11 passed (11)
+```
-TypeScript helped auto-completing and verifying the correct use of types (ensuring they are in sync with the database schema).
+## Summary and next steps
-You used [Prisma Client](https://www.prisma.io/docs/orm/prisma-client) extensively to fetch and persist data in the database.
+You implemented email-based passwordless authentication with Prisma, Hono, and JWT, and added authorization with reusable middleware. Along the way you migrated the database to add a `Token` model with a one-to-many relation to `User`, and adapted the tests to authenticate with real tokens.
-The article covered authorization for a subset of all the endpoints. As next steps, you could do the following:
+The authentication middleware verifies each request's JWT against the database and populates a typed `credentials` object; the authorization middlewares read that object to allow or deny access. Because everything is plain middleware and the schema is the single source of truth for the token types, the types you query against are the same types Prisma generates.
-- Add authorization to the rest of the routes following the same principles.
-- Add the credential object to all the tests.
-- Generate and set the `JWT_SECRET` environment variable.
-- Set the `SENDGRID_API_KEY` environment variable and test the email functionality.
+As next steps, you could:
-You can find the full source code on [GitHub](https://github.com/2color/real-world-grading-app) with authorization rules for all the endpoints implemented, and the tests adapted.
+- Add authorization to the course and test routes with `isTeacherOfCourseOrAdmin`.
+- Set the `JWT_SECRET` and `RESEND_API_KEY` environment variables in production.
-While Prisma aims to make working with relational databases easy, it's useful to understand the underlying database and authentication principles.
+Your database runs on [Prisma Postgres](https://www.prisma.io/postgres), which you can manage in the [Prisma Console](https://console.prisma.io). If AI agents are part of your workflow, the [Prisma MCP server](https://www.prisma.io/docs/postgres/integrations/mcp-server) lets them create and manage databases as they need them. In the next and final part of this series, you'll set up CI and deploy the backend with [Prisma Compute](https://www.prisma.io/compute), which runs your app on the same platform as your database.
-If you have questions, feel free to reach out on [Twitter](https://twitter.com/daniel2color).
+This series teaches Prisma ORM 7, the current production release. To see where Prisma ORM is heading for AI-assisted development, read [The Next Evolution of Prisma ORM](https://www.prisma.io/blog/the-next-evolution-of-prisma-orm). Prisma Next is the agent-native evolution of the ORM, available in Early Access today, and it becomes Prisma 8 at GA. The type-safe, schema-as-contract approach you used here, where the same models drive your queries, your credentials, and your migrations, is exactly what Prisma Next builds on, with structured output that humans and coding agents can build on safely. The [performance benchmark](https://www.prisma.io/blog/prisma-next-performance-benchmark) covers what the rewrite means for throughput and client size.