Dev.to Security 🔐 Cybersecurity 👁 0 📖 13 min read

Building a Production-Ready Authentication System with Next.js

Authentication is one of those topics that looks simple at first. You create a login form: Email + Password ↓ Login ↓ User authenticated But a real authentication system is much more than

Building a Production-Ready Authentication System with Next.js


Authentication is one of those topics that looks simple at first.

You create a login form:

Email + Password
       ↓
     Login
       ↓
   User authenticated

But a real authentication system is much more than checking whether a password is correct.

A production authentication system needs to answer questions such as:

  • How do we securely store passwords?
  • Where should authentication tokens live?
  • How does the server know that a request belongs to an authenticated user?
  • How do we keep users logged in?
  • How do we refresh expired access tokens?
  • How do we log users out?
  • How do we protect routes?
  • How do we distinguish authentication from authorization?
  • How do we prevent token theft and replay attacks?
  • How does this work with Next.js Server Components and Client Components?
  • How should the architecture change when the application grows?

This article builds an authentication system from the ground up using Next.js, while focusing on the architectural ideas behind it rather than simply copying an authentication library.

1. Authentication vs Authorization

Before writing code, we need to understand two different concepts.

Authentication

Authentication answers:

Who are you?

For example:

Email: [email protected]
Password: ********

The server verifies the credentials and identifies the user:

User ID: 123
Email: [email protected]

Authorization

Authorization answers:

What are you allowed to do?

For example:

User
 ├── Read posts
 ├── Create posts
 └── Delete own posts

Admin
 ├── Read posts
 ├── Create posts
 ├── Delete posts
 └── Manage users

So:

Authentication → Identity

Authorization → Permissions

This distinction becomes extremely important when designing the backend.

2. The Authentication Architecture

Let's imagine that we have a Next.js application.

A simplified architecture can look like this:

                    Browser
                       │
                       │ HTTPS
                       ▼
                ┌──────────────┐
                │   Next.js    │
                │ Application  │
                └──────┬───────┘
                       │
             ┌─────────┴─────────┐
             │                   │
             ▼                   ▼
        Authentication       Application
           Logic                Logic
             │                   │
             └─────────┬─────────┘
                       ▼
                   Database

The authentication system will be responsible for:

Register
Login
Logout
Session validation
Token refresh
Password hashing
Password reset
Email verification
Authorization

3. The User Model

At the database level, we need a user.

A simplified model might look like:

User {
  id
  name
  email
  passwordHash
  role
  emailVerified
  createdAt
  updatedAt
}

Notice something important:

We don't store:

password

We store:

passwordHash

This is a fundamental security rule.

4. Never Store Plain-Text Passwords

Suppose the user creates:

Password:

MySecretPassword123

We should never save:

password = "MySecretPassword123"

Instead:

Password
   ↓
Hashing Algorithm
   ↓
Password Hash
   ↓
Database

For example:

MySecretPassword123
        ↓
$argon2id$v=19$...

The important property is that hashing is one-way.

We don't decrypt the password during login.

Instead:

Login Password
      ↓
Hash/Verify
      ↓
Compare with stored hash

A modern password hashing algorithm such as Argon2id is a strong choice for password storage.

5. Registration Flow

Let's design the registration process.

The browser sends:

POST /api/auth/register

with:

{
  "name": "Abanoub",
  "email": "[email protected]",
  "password": "MySecretPassword123"
}

The server performs:

Receive request
      ↓
Validate input
      ↓
Normalize email
      ↓
Check existing user
      ↓
Hash password
      ↓
Create user
      ↓
Create authentication state
      ↓
Return response

6. Input Validation

Never trust input coming from the browser.

For example:

const schema = z.object({
  name: z.string().min(2).max(100),

  email: z
    .string()
    .email(),

  password: z
    .string()
    .min(8)
});

Then:

const result = schema.safeParse(body);

if (!result.success) {
  return Response.json(
    {
      message: "Invalid input"
    },
    {
      status: 400
    }
  );
}

The client-side validation improves UX.

The server-side validation provides security and correctness.

You need both.

7. Normalize Email Addresses

Authentication systems should usually normalize emails before querying the database.

For example:

const email = input.email
  .trim()
  .toLowerCase();

Without normalization:

could accidentally be treated as different values depending on the database and application rules.

Ideally, the database should also enforce a unique constraint.

8. Login Flow

Now let's look at login.

The browser sends:

POST /api/auth/login
{
  "email": "[email protected]",
  "password": "MySecretPassword123"
}

The server performs:

Validate input
      ↓
Find user
      ↓
Verify password
      ↓
Create authentication state
      ↓
Set secure cookie
      ↓
Return response

The browser doesn't need to know the user's password after authentication.

9. How Does the Server Remember the User?

This is the core authentication problem.

HTTP is stateless.

For example:

Request 1
GET /dashboard

Request 2
GET /profile

Request 3
GET /orders

Each request is independent.

The server needs some form of authentication state to understand:

These requests belong to user 123.

There are two major approaches:

Session-based authentication
JWT-based authentication

10. Session-Based Authentication

With sessions, the server stores authentication state.

For example:

Database

Session
-------------------------
sessionId
userId
expiresAt
createdAt

The browser receives:

sessionId

inside a cookie.

Then:

Browser
   │
   │ Cookie: sessionId=abc123
   ▼
Next.js
   │
   │ lookup session
   ▼
Database
   │
   ▼
User 123

This is a very clean model for traditional web applications.

11. JWT-Based Authentication

Another approach is JSON Web Tokens.

A JWT contains claims such as:

{
  "sub": "123",
  "role": "user",
  "exp": 1790000000
}

The token is cryptographically signed.

The server can verify:

Signature
Expiration
Issuer
Audience
Claims

without necessarily querying the database for every request.

12. Access Token vs Refresh Token

A common production architecture separates authentication into two tokens.

Access Token
+
Refresh Token

The access token is short-lived.

For example:

Access Token
Expires in 15 minutes

The refresh token lives longer:

Refresh Token
Expires in days/weeks

The idea is:

                Login
                  │
          ┌───────┴────────┐
          ▼                ▼
    Access Token      Refresh Token
      short-lived       long-lived

13. Why Not Use a Long-Lived Access Token?

Imagine:

Access Token
Expires in 30 days

If an attacker steals it:

Attacker
   ↓
Stolen token
   ↓
30 days of access

A short-lived access token reduces the lifetime of a stolen credential.

The refresh mechanism then allows legitimate users to obtain a new access token.

14. Where Should Tokens Be Stored?

This is one of the most important authentication design decisions.

A common mistake is:

localStorage.setItem(
  "accessToken",
  token
);

This can expose the token to JavaScript running in the page.

If an XSS vulnerability exists, malicious JavaScript could potentially read the token.

A common safer browser architecture is:

HttpOnly Cookie
Secure Cookie
SameSite Cookie

For example:

Set-Cookie:
refreshToken=...
HttpOnly;
Secure;
SameSite=Lax;
Path=/api/auth;

The HttpOnly flag means JavaScript cannot access the cookie through:

document.cookie

15. Cookie Security

A production authentication cookie should generally consider:

HttpOnly
Secure
SameSite
Path
Expiration

For example:

cookies().set(
  "refreshToken",
  refreshToken,
  {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/api/auth",
    maxAge: 60 * 60 * 24 * 30
  }
);

The exact configuration depends on your architecture and deployment environment.

16. SameSite Is Not a Complete CSRF Strategy

A common misconception is:

HttpOnly completely solves authentication security.

It doesn't.

HttpOnly primarily prevents JavaScript from reading the cookie.

It does not automatically solve every CSRF scenario.

That's why authentication systems should consider:

SameSite cookies
+
CSRF protection where necessary
+
Origin checking
+
Secure application design

Especially when the application performs state-changing requests.

17. The Authentication Request Lifecycle

Let's put everything together.

Suppose the user requests:

GET /dashboard

The browser automatically sends:

Cookie: session=abc123

or:

Cookie: refreshToken=...

The server:

Request
   ↓
Read cookie
   ↓
Validate authentication
   ↓
Identify user
   ↓
Check authorization
   ↓
Execute business logic
   ↓
Return response

This is the central lifecycle of authentication.

18. Authentication Helper

Instead of repeating authentication logic everywhere, create a reusable function.

For example:

async function getCurrentUser() {
  const cookieStore = await cookies();

  const token = cookieStore.get(
    "accessToken"
  )?.value;

  if (!token) {
    return null;
  }

  try {
    const payload = await verifyToken(token);

    return await getUserById(payload.sub);
  } catch {
    return null;
  }
}

Now application code can simply do:

const user = await getCurrentUser();

if (!user) {
  // unauthenticated
}

This is much cleaner than implementing token parsing in every route.

19. Authentication in Next.js

Next.js introduces an interesting architectural distinction:

Server Components
Client Components
Route Handlers
Middleware
Server Actions

Authentication logic should be placed carefully according to what it needs to do.

For example, if you need to read an HttpOnly cookie, server-side code is a natural place.

A Server Component can retrieve authentication information and render accordingly.

20. Protecting Server Components

Imagine:

export default async function DashboardPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect("/login");
  }

  return (
    <main>
      <h1>
        Welcome {user.name}
      </h1>
    </main>
  );
}

The important idea is:

Server
  ↓
Read authentication state
  ↓
Validate user
  ↓
Render protected content

The browser doesn't need to decide whether the user is authenticated.

21. Middleware

Middleware can be useful for early route protection.

For example:

/dashboard
/profile
/settings
/admin

could be protected.

Conceptually:

export async function middleware(request) {
  const token =
    request.cookies.get("accessToken");

  if (!token) {
    return Response.redirect(
      new URL("/login", request.url)
    );
  }

  return NextResponse.next();
}

But middleware should not automatically become the only authorization layer.

Why?

Because authorization must also happen at the point where sensitive data or operations are accessed.

22. Never Trust Middleware Alone

Suppose:

POST /api/users/delete

is protected by middleware.

An attacker may still try to call the underlying endpoint directly.

Therefore:

Middleware
   ↓
Early protection

Route Handler / Server Action
   ↓
Actual authorization

The sensitive operation itself should verify permissions.

For example:

const user = await getCurrentUser();

if (!user) {
  return unauthorized();
}

if (user.role !== "admin") {
  return forbidden();
}

23. Authentication vs Authorization in Code

A clean structure is:

const user = await requireUser();

requireRole(user, "admin");

Conceptually:

requireUser()
      ↓
Is authenticated?

requireRole()
      ↓
Is authorized?

This separation makes the system easier to understand and test.

24. Role-Based Access Control

Suppose our application has:

type Role =
  | "user"
  | "admin"
  | "manager";

Then:

User
 ├── View profile
 └── Create orders

Manager
 ├── View reports
 └── Manage orders

Admin
 ├── Manage users
 ├── Manage roles
 └── Manage system settings

Authorization becomes:

if (user.role !== "admin") {
  return forbidden();
}

But real systems can go beyond roles.

25. Permissions

Instead of:

role = admin

we can model permissions:

users:read
users:create
users:update
users:delete
orders:read
orders:create
orders:update

Then:

hasPermission(
  user,
  "users:delete"
);

This becomes useful when the application becomes more complex.

26. Logout

Logout should invalidate the authentication state.

For a cookie-based architecture:

User clicks Logout
        ↓
POST /api/auth/logout
        ↓
Invalidate session/token
        ↓
Clear cookie
        ↓
Redirect to login

For example:

cookies().delete("refreshToken");

If using server-side sessions, you should also invalidate the session in the database.

27. Refresh Token Rotation

A stronger refresh-token architecture uses rotation.

Suppose:

Refresh Token A

is used.

The server returns:

Refresh Token B

and invalidates A.

So:

Token A
   ↓
Refresh
   ↓
Token B
   ↓
Token A invalid

This can reduce the impact of stolen refresh tokens.

You can also detect token reuse.

28. Refresh Token Storage

For a high-security architecture, don't necessarily store the raw refresh token in the database.

Instead:

Refresh Token
      ↓
Hash
      ↓
Database

The database stores something like:

tokenHash
userId
expiresAt
revokedAt
createdAt

Then the server hashes the presented token and looks up the hash.

This follows the same general principle:

Don't store sensitive credentials in recoverable plain text when you don't need to.

29. Session-Based Architecture Can Be Even Simpler

For many Next.js applications, a server-side session architecture is extremely practical:

Browser
   │
   │ session cookie
   ▼
Next.js
   │
   │ session lookup
   ▼
Database / Redis

The browser stores only an opaque identifier.

For example:

session_id = 7f3a...

The server knows:

7f3a...
   ↓
User 123

This gives the server direct control over session revocation.

30. JWT Does Not Automatically Mean "More Secure"

JWT is a technology, not a security strategy.

A poorly designed JWT system can be vulnerable.

For example:

JWT in localStorage
+
Long expiration
+
No rotation
+
No revocation strategy
+
Weak secret management

is not automatically better than sessions.

The correct question is:

What authentication model fits the architecture and security requirements of the application?

31. Password Reset

Authentication isn't complete without password recovery.

A typical flow is:

User clicks:
Forgot Password
        ↓
Enter email
        ↓
Server creates reset token
        ↓
Email sent
        ↓
User clicks link
        ↓
Validate token
        ↓
Set new password

The reset token should be:

random
short-lived
single-use

And ideally the database stores only a hash of the reset token.

32. Email Verification

Registration can also require email verification.

Flow:

Register
   ↓
Create user
   ↓
emailVerified = false
   ↓
Generate verification token
   ↓
Send email
   ↓
User clicks link
   ↓
Verify token
   ↓
emailVerified = true

Then sensitive operations can require:

if (!user.emailVerified) {
  // require verification
}

33. Brute-Force Protection

Imagine an attacker sends:

10,000 login attempts

against:

POST /api/auth/login

The authentication endpoint should be rate-limited.

For example:

IP
+
Email
+
Time Window

could be used to detect excessive attempts.

A production system might use:

Redis

to implement distributed rate limiting.

Conceptually:

login:ip:1.2.3.4
login:user:[email protected]

34. Account Enumeration

Be careful with error messages.

Avoid:

User does not exist.

and:

Wrong password.

because this can allow attackers to determine whether an email address is registered.

A generic response can be:

Invalid email or password.

Similarly, password-reset requests often use generic responses so attackers cannot easily discover registered accounts.

35. Secrets and Environment Variables

Authentication secrets should never be hardcoded.

Bad:

const secret = "my-secret-123";

Better:

AUTH_SECRET=...
DATABASE_URL=...

Then:

process.env.AUTH_SECRET

Also:

.env.local

should not be committed to Git.

36. HTTPS

Authentication should be performed over HTTPS in production.

Why?

Because credentials, cookies, and authentication tokens must be protected while traveling between:

Browser
    ↕
Internet
    ↕
Server

Without transport encryption, an attacker positioned on the network could potentially intercept sensitive data.

37. Secure Cookies

In production:

Secure = true

means the cookie should only be transmitted over HTTPS.

A typical production cookie might look conceptually like:

HttpOnly
Secure
SameSite=Lax

Again, the correct SameSite policy depends on whether your application uses cross-site authentication flows.

38. Database Constraints Are Part of Security

Application-level checks are not enough.

For example:

if (existingUser) {
  throw new Error("Email already exists");
}

is useful.

But the database should also enforce:

UNIQUE(email)

Why?

Because two requests could arrive at almost the same time:

Request A ─┐
           ├── Check email
Request B ─┘

Both might see:

No user exists

and then both try to insert.

The database constraint provides the final guarantee.

39. Race Conditions

Authentication systems contain many opportunities for race conditions.

Examples:

Two registration requests
Two password reset requests
Two refresh requests
Two logout requests
Two email verification requests

Good architecture considers these cases explicitly.

For refresh-token rotation, for example, concurrent refresh requests require careful handling.

40. CSRF

If authentication relies on cookies, the browser automatically sends them with requests.

That creates a CSRF consideration.

Imagine:

Attacker Website
       ↓
POST /api/change-email
       ↓
Browser automatically sends auth cookie

Depending on the cookie and request configuration, the server may receive an authenticated request.

Possible defenses include:

SameSite cookies
CSRF tokens
Origin validation
Same-origin architecture

The correct strategy depends on the application.

41. XSS

Authentication security also depends on preventing XSS.

For example, never render untrusted HTML without sanitization.

An XSS vulnerability can allow malicious JavaScript to perform actions as the user, even when HttpOnly cookies prevent direct token theft.

Therefore:

Authentication Security
        ≠
Token Security Only

It is an application-wide concern.

42. A Better Next.js Project Structure

A growing application might use a structure such as:

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   ├── register/
│   │   └── forgot-password/
│   │
│   ├── dashboard/
│   ├── profile/
│   │
│   └── api/
│       └── auth/
│           ├── login/
│           ├── register/
│           ├── logout/
│           ├── refresh/
│           └── reset-password/
│
├── lib/
│   ├── auth/
│   │   ├── session.ts
│   │   ├── password.ts
│   │   ├── tokens.ts
│   │   └── authorization.ts
│   │
│   ├── db/
│   └── validation/
│
├── components/
│
└── middleware.ts

The exact structure isn't important.

The separation of responsibilities is.

43. Authentication Service

Instead of mixing everything into route handlers:

export async function login(
  email: string,
  password: string
) {
  const user = await findUserByEmail(email);

  if (!user) {
    throw new AuthenticationError();
  }

  const valid = await verifyPassword(
    password,
    user.passwordHash
  );

  if (!valid) {
    throw new AuthenticationError();
  }

  return createSession(user);
}

Then the route handler becomes thin:

export async function POST(request: Request) {
  const body = await request.json();

  const result = await login(
    body.email,
    body.password
  );

  return Response.json(result);
}

This makes the authentication logic reusable and testable.

44. The Thin Controller Principle

A good API layer should ideally do:

Request
   ↓
Validation
   ↓
Service
   ↓
Response

Instead of:

Route Handler
 ├── validate
 ├── query DB
 ├── hash password
 ├── generate token
 ├── create session
 ├── send email
 ├── authorization
 ├── business logic
 └── response

The second approach becomes difficult to maintain.

45. A Complete Login Architecture

Let's summarize the complete login process.

                 Browser
                    │
                    │ POST /login
                    ▼
              Next.js Route
                    │
                    ▼
             Validate Input
                    │
                    ▼
             Find User
                    │
                    ▼
            Verify Password
                    │
                    ▼
           Create Session
                    │
                    ▼
          Set HttpOnly Cookie
                    │
                    ▼
               Response

Then:

Browser
   │
   │ Cookie
   ▼
Next.js
   │
   ▼
Validate Session
   │
   ▼
Identify User
   │
   ▼
Authorization
   │
   ▼
Business Logic

46. The Mental Model

The most important mental model is this:

Credentials
    ↓
Identity
    ↓
Authentication State
    ↓
Authenticated Request
    ↓
Authorization
    ↓
Business Operation

For example:

Email + Password
       ↓
User #123
       ↓
Session #ABC
       ↓
GET /dashboard
       ↓
Authenticated User
       ↓
Permission: dashboard:read
       ↓
Dashboard Data

Once you understand this flow, authentication stops being a collection of random APIs and becomes a system.

47. Production Authentication Checklist

Before considering an authentication system production-ready, ask:

Passwords

  • [ ] Are passwords hashed with a password-specific algorithm?
  • [ ] Are password hashes never exposed through APIs?
  • [ ] Is password validation performed server-side?

Cookies

  • [ ] Are sensitive cookies HttpOnly?
  • [ ] Is Secure enabled in production?
  • [ ] Is SameSite configured correctly?
  • [ ] Is the cookie scope as narrow as practical?

Tokens / Sessions

  • [ ] Do authentication credentials expire?
  • [ ] Can sessions be revoked?
  • [ ] Are refresh tokens rotated where appropriate?
  • [ ] Are refresh tokens protected?
  • [ ] Is token reuse detectable where required?

APIs

  • [ ] Are authentication endpoints rate-limited?
  • [ ] Are inputs validated?
  • [ ] Are authorization checks performed server-side?
  • [ ] Are sensitive errors generic enough to avoid account enumeration?

Infrastructure

  • [ ] Is HTTPS enabled?
  • [ ] Are secrets stored in environment/secret management?
  • [ ] Are database constraints enforced?
  • [ ] Are logs free from passwords and tokens?

Application Security

  • [ ] Is CSRF considered?
  • [ ] Is XSS considered?
  • [ ] Are permissions checked at the resource boundary?
  • [ ] Are authentication and authorization separated?

48. The Most Important Architectural Lesson

Authentication is not:

Login Page
+
JWT
+
Middleware

Authentication is a complete security boundary.

A useful architecture looks like:

                 ┌───────────────────────┐
                 │       Browser         │
                 └───────────┬───────────┘
                             │
                             ▼
                    ┌────────────────┐
                    │    Next.js     │
                    │                │
                    │ Route Handlers │
                    │ Server Actions │
                    │ Server Comps   │
                    │ Middleware     │
                    └───────┬────────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
         Auth Service   Authorization   Business
              │             │             │
              └─────────────┼─────────────┘
                            ▼
                       Database
                            │
                            ▼
                     Session / User

The goal isn't simply to make the login button work.

The goal is to establish a trustworthy chain:

Who is this user?
        ↓
How do we know?
        ↓
Is their authentication state valid?
        ↓
What are they allowed to do?
        ↓
Can we safely execute the operation?

That's the real meaning of building an authentication system.

Conclusion

Building authentication with Next.js is an excellent exercise because it forces you to combine several areas of software engineering:

HTTP
Cookies
Cryptography
Databases
API Design
Security
Authorization
Server-side Rendering
Client-side Applications
Middleware
Distributed Systems

The most important thing to learn is not a specific library or token format.

It's the architecture.

Once you understand:

Authentication
        ↓
Session / Token
        ↓
Request
        ↓
Identity
        ↓
Authorization
        ↓
Business Logic

you can apply the same concepts whether you're building a Next.js application, an Angular + Node.js system, a mobile backend, or a distributed API.

A good authentication system should make it difficult for an attacker to impersonate a user, limit the impact of stolen credentials, provide clear authorization boundaries, and give the server reliable control over access.

That is what turns a simple login feature into a production authentication system.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.