Command Palette

Search for a command to run...

Next JS Authentication Core

Timeline

Built With

NextJS

Topics

Introduction

This post documents my process of building authentication in Next.js from scratch without relying on heavy libraries or abstractions. The goal is to understand the mechanics, not just the API surface. I’m following the Next.js Authentication Master Class(opens in a new tab) tutorial but adapting it to my own preferences. This project comes right after my deep dives into a Go HTTP server and Go Authentication and Authorization. Throughout this post, I will contrast this Node.js implementation with the Go strategies I used previously.

I created a basic frontend template in Next.js using TailwindCSS, ShadcnUI, and Drizzle ORM. This post focuses purely on the backend logic, so I won't detail the UI setup.

Password Hashing

The first step is securing user credentials. We don't need to become cryptographers to do this right—we just need to understand the tools. Node.js includes a built-in crypto library which offers scrypt, a password-based key derivation function.

The original scrypt function in Node.js uses a callback pattern. While the tutorial author manually wrapped this in a Promise to modernize it, I prefer using the standard util.promisify. This utility converts callback-based functions into specific Promise implementations automatically, keeping the codebase linear and readable.

The normalize() function is critical here. It ensures that the input string uses a standard Unicode representation. Without this, the same visual character—like an "ñ"—could be represented by different byte sequences on different keyboards, causing password verification to fail. We use a key length of 64 bytes to align with the SHA-512 standard, ensuring maximum entropy and resistance against brute-force attacks.

I use an explicit type assertion as Buffer because util.promisify struggles to infer the complex overloads of crypto.scrypt. The documentation confirms that scrypt passes a derivedKey of type NonSharedBuffer (a subtype of Buffer) to its callback, so this assertion is safe.

Using the same salt value results in the same hashed password. This predictability is a security flaw, which is why we must dynamically generate a unique salt for every user. In our user creation logic later on, we will need to store this unique salt alongside the user details in the database so we can retrieve it during login.

Sidenote: The Production Standard

In a real production environment—even when building auth "from scratch"—we rarely manage salts manually or use raw crypto.scrypt. The current gold standard for password hashing is the Argon2id algorithm.

Popular libraries like node-argon2 abstract away the salt generation entirely. The output is a PHC (Password Hashing Competition) string. This string bundles the algorithm identifier, the configuration parameters, the unique salt, and the hash into a single line.

Because the salt is embedded directly in the output string, we don't need to create or manage a separate "salt" column in our database. We just store the single hash string.

If we were using Argon2, the implementation would look like this:

This is significantly cleaner, but for this project, I will stick to the manual scrypt implementation to fully understand the moving parts of salt management.

Session Management

Before implementing the session logic, we need infrastructure. Session-based authentication relies on speed; checking a database for every single request is inefficient. Instead, we use Redis—an in-memory key-value store—to handle active sessions.

While the tutorial suggests Upstash (a serverless Redis provider), I prefer spinning up my own instances locally using Docker to keep development self-contained.

Running docker compose up -d will spin up both the Postgres database and the Redis instance.

Setting up the Redis Client

Since we are hosting Redis ourselves, we just need the connection string and a client library. I use ioredis, which is a robust, feature-rich Redis client for Node.js.

Note on Environment Variables: You might notice I import env from @/env instead of using process.env. I use t3-env(opens in a new tab) to enforce type safety and enable autocomplete for my environment variables. If you prefer a standard setup, you can simply swap env.REDIS_URL for process.env.REDIS_URL.

The Sign-Up Workflow (Server Action)

We start with the server action. Although specific to Next.js, the logic here follows a universal backend pattern: Validate -> Check Existence -> Hash -> Persist -> Authorize.

To manage sessions securely, we need to store the Session ID in a browser cookie with strict flags (HttpOnly, Secure). While we could use next/headers directly inside our logic, the tutorial adopts a Dependency Injection approach. We define a generic Cookies interface. This decouples our authentication logic from the framework, making it easier to test or port to a different backend (like Express or Fastify) later without rewriting the core logic.

Storing the Session

We need to define the session schema and expiration rules. Redis stores data as strings, so we must serialize our user object before saving it. In the function declaration for createUserSession, you can see I use cookies: Pick<Cookies, "set"> instead of just cookies: Cookies . This is just a better developer experience , especially when it comes to later down the line, and if you need to write test cases for every function. By using cookies: Pick<Cookies, "set">, you only need to test for the set method. If you use the general Cookies type, you would have to write dummy code to test the other get and delete method which is not required in this function.

Verification

Once the sign-up flow is triggered, we can verify the data persistence in two places.

1. Browser Storage: Inspect the browser's DevTools (Application > Cookies). You should see a session_id cookie with HttpOnly and Secure flags checked.

2. Redis (via Docker CLI): We can inspect the Redis container directly to confirm the session exists.

Sign In Logic

The sign-in process mirrors sign-up, but with one critical addition: password verification. We cannot simply compare strings using ===. If we did, an attacker could measure how long the server takes to reject a password and deduce how many characters they guessed correctly. To prevent these timing attacks, we use crypto.timingSafeEqual.

The server action is straightforward: Validate Input -> Fetch User -> Verify Password -> Create Session.

Session Retrieval & Removal

We need two utility functions to handle the session lifecycle. One to log out—removing the session—and one to fetch the current user.

I've introduced a constant COOKIE_SESSION_KEY — set to "session_id" to avoid "magic strings" scattered across the codebase.

Why split getUserFromSession and getUserFromSessionById?

At first glance, this looks redundant. However, this separates the Transport Layer from the Service Layer.

  • getUserFromSession: Knows about Cookies — the transport layer. It extracts the ID.
  • getUserFromSessionById: Knows about Redis — the data source. It fetches the user.

If we later expand to a mobile app that uses Bearer Tokens instead of cookies, we can simply write a getUserFromAuthHeader() function that reuses getUserFromSessionById() without duplicating the Redis logic.

Optimization: Request Memoization

In Next.js, it is common to need user data in multiple places during a single request, such as the Layout, the Navbar, and the Page itself. Without caching, we would hit Redis three times for the same data.

We can use the React cache function to implement Request Memoization. This ensures that getUserFromSession is called only once per request, and the result is shared across components.

Now, checking the user in the UI is clean and efficient:

Proxy — The New Middleware

In Next.js 16, the file convention middleware.ts was renamed to proxy.ts. This change isn't just cosmetic; it clarifies that this file acts as a network proxy that intercepts requests before they reach your React components.

Our goal is to protect two route groups:

  1. <Url>/private</Url>: Accessible only by signed-in users. Redirects anonymous users to Sign In.
  2. <Url>/admin</Url>: Accessible only by users with the admin role. Redirects non-admins to Home.

We could enforce this at the page level:

However, handling this in the Proxy is more secure and scalable because it prevents the route from even rendering.

Testing: The Role Toggler

Before writing the proxy logic, we need a way to test our permissions. Since we can't easily edit the database manually every time, let's build a quick helper to toggle our role between "user" and "admin.".

Implementing the Proxy

Create the proxy.ts file in your root directory.

I optimized the logic flow slightly compared to the tutorial. Instead of checking routes sequentially, I determine the route type first. This prevents double-checking the session if a route happens to be both private and admin-protected in the future.

Sliding Window Session

The "Sliding Window" pattern keeps users logged in as long as they are active. To implement this, we need a helper function that updates the expiration time in both Redis and the browser cookie.

The adapter pattern in the proxy.ts file is particularly interesting here.

We are in a unique position where we read from the Request — incoming from browser but must write to the Response — outgoing to browser. Standard cookie libraries usually assume you are working with one or the other. By creating this adapter on the fly, we bridge the gap:

//http://localhost:3000/api/oauth/discord?code=oiE2WskI2cubRbCUi7FY2soN1TuCMQ(opens in a new tab)

OAuth 2.0 Implementation

Authenticating with third-party providers like Google or Discord requires a shift in our database strategy. Since users logging in via OAuth won't have a password or a salt, we must first update our schema to make these fields nullable. We also need a new table to link these external identities to our internal user records.

Database Schema Updates

We update the users table and create a new user_oauth_accounts table. This allows a single user to potentially link multiple providers to one account in the future.

After running the migration, we need to register our application with the OAuth providers. The setup varies slightly per provider, but the core requirement is identical: we get a Client ID and a Client Secret, and we must provide a Redirect URL.

For this project, I am standardizing the redirect URL structure to <Url>/api/oauth/[provider]/route.ts</Url>.

The Architecture: Class-Based Clients

Most modern React development relies on functional components. However, OAuth clients are a textbook use case for Object-Oriented Programming.

We are essentially building a blueprint. Whether we use Discord, Google, or GitHub, the flow is the same:

  1. Generate an authorization URL.
  2. Exchange the code for a token.
  3. Fetch user details.

Using a class allows us to define this logic once and instantiate it with different configurations for each provider. If we used functional components here, we would end up with a messy web of switch statements handling configuration secrets and URLs.

The createAuthUrl method constructs the link that sends the user to the provider's consent page. Note the state and code_challenge parameters—these are critical security measures we will discuss in the Deep Dive section.

Handling the Callback

Once the user approves the login, the provider redirects them back to our application at <Url>/api/oauth/[provider]</Url> with a code. We need a route handler to intercept this, validate it, and create the user session.

This route relies on a database transaction helper connectUserToAccount. This ensures we don't end up with "half-created" users. It checks if the email exists, creates the user if needed, and then links the OAuth provider record.

Fetching the Token and User

Back in our OAuthClient class, we implement the exchange logic. This involves two steps. First, we POST the authorization code to the provider to get an access token. Second, we use that token to GET the user's profile.

Understanding the Token Exchange

The fetchToken function is where we adhere to the strict OAuth 2.0 standard.

We must set the content type to application/x-www-form-urlencoded rather than JSON. This is a requirement of the OAuth specification. We also explicitly define the grant_type as authorization_code. This tells the provider that we are exchanging a temporary code for a long-lived token.

Once we have the token, we proceed to fetchUser. The URL <Url>https://discord.com/api/users/@me</Url> is specific to Discord. If we were implementing GitHub or Google, we would need to swap this for their respective user profile endpoints. We pass the access token in the Authorization header, allowing the provider to verify our identity and return the user's data.

Atomic Database Operations

Back in <File>route.ts</File>, we receive the user data and pass it to connectUserToAccount.

This function wraps our database logic in a Transaction. This is critical for data integrity. We need to perform two distinct writes: creating the User record and creating the UserOAuthAccount link. If the server crashed halfway through—creating the user but failing to link the provider—we would have an orphaned account that the user could never access again.

The transaction ensures Atomicity: either both operations succeed together, or the entire attempt fails and rolls back, leaving the database clean.

Once the user is persisted, we create a session just as we did in the standard email/password flow. This completes the cycle, logging the user in successfully.