Authentication Best Practices for Modern SaaS

Implement secure, user-friendly authentication with magic links, OAuth, and session management using Auth.js.

SaaS Team
6 min read

Authentication is the gateway to your application. Get it wrong, and you risk losing users to friction or, worse, exposing their data. Let's explore how to implement authentication that's both secure and delightful.

Why Auth.js?

Auth.js (formerly NextAuth.js) has become the standard for Next.js authentication. Here's why:

  • Battle-tested: Used by thousands of production applications
  • Flexible: Support for multiple providers and strategies
  • Secure by default: Handles CSRF, session management, and token rotation
  • TypeScript-first: Excellent type safety

Magic links provide passwordless authentication via email. Users enter their email, receive a link, and click to sign in.

  1. No password fatigue: Users don't need to remember another password
  2. Inherent email verification: You know the email is valid
  3. Phishing resistant: Links are unique and time-limited
  4. Simple UX: Just enter email and check inbox

Implementation

// src/lib/auth/index.ts
import NextAuth from "next-auth";
import Resend from "next-auth/providers/resend";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Resend({
      from: "auth@yourdomain.com",
      // Custom email template
      sendVerificationRequest: async ({ identifier, url }) => {
        await resend.emails.send({
          from: "YourApp <auth@yourdomain.com>",
          to: identifier,
          subject: "Sign in to YourApp",
          html: magicLinkTemplate(url),
        });
      },
    }),
  ],
});

Email Template Best Practices

Your magic link email should:

  • Clearly identify your app
  • Show the email address being used
  • Have a prominent call-to-action button
  • Include a plain-text link alternative
  • Mention the expiration time
  • Provide a way to report suspicious activity
function magicLinkTemplate(url: string) {
  return `
    <div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
      <h1 style="color: #333;">Sign in to YourApp</h1>
      <p>Click the button below to sign in. This link expires in 24 hours.</p>
      <a href="${url}" style="
        display: inline-block;
        padding: 12px 24px;
        background: #da7b0d;
        color: white;
        text-decoration: none;
        border-radius: 6px;
      ">
        Sign in
      </a>
      <p style="color: #666; font-size: 14px; margin-top: 24px;">
        If you didn't request this email, you can safely ignore it.
      </p>
    </div>
  `;
}

Adding OAuth Providers

OAuth lets users sign in with existing accounts (Google, GitHub, etc.). It's convenient but comes with considerations.

Google OAuth Setup

import Google from "next-auth/providers/google";

export const { handlers, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
  ],
});

OAuth Considerations

  1. Account linking: What happens if a user signs up with email, then tries Google with the same email?
  2. Required scopes: Only request what you need
  3. Provider downtime: Have fallback authentication
  4. Data privacy: Users may be wary of OAuth permissions

Account Linking Strategy

Handle multiple auth methods for the same email:

// In your auth config
callbacks: {
  async signIn({ user, account }) {
    // Check if email exists with different provider
    const existingUser = await prisma.user.findUnique({
      where: { email: user.email },
      include: { accounts: true },
    });

    if (existingUser && !existingUser.accounts.some(
      a => a.provider === account.provider
    )) {
      // Link new provider to existing account
      await prisma.account.create({
        data: {
          userId: existingUser.id,
          provider: account.provider,
          providerAccountId: account.providerAccountId,
          // ... other account fields
        },
      });
    }

    return true;
  },
}

Session Management

Auth.js supports both JWT and database sessions. Here's when to use each:

JWT Sessions

session: {
  strategy: "jwt",
  maxAge: 30 * 24 * 60 * 60, // 30 days
}

Pros: Stateless, no database queries for validation Cons: Can't invalidate individual sessions, larger cookies

Database Sessions

session: {
  strategy: "database",
  maxAge: 30 * 24 * 60 * 60,
}

Pros: Can invalidate sessions, smaller cookies Cons: Database query on every request

Our Recommendation

Use database sessions for SaaS applications. The ability to invalidate sessions is crucial for:

  • Security incidents
  • User-initiated "log out everywhere"
  • Subscription changes requiring re-authentication

Protecting Routes

Middleware Protection

// src/middleware.ts
import { auth } from "@/lib/auth";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");

  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl));
  }
});

export const config = {
  matcher: ["/dashboard/:path*"],
};

Server Component Protection

import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

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

  return <Dashboard user={session.user} />;
}

Security Hardening

Rate Limiting

Prevent brute force attacks on authentication endpoints:

import { Ratelimit } from "@upstash/ratelimit";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 attempts per minute
});

// In your sign-in handler
const { success } = await ratelimit.limit(ip);
if (!success) {
  return new Response("Too many attempts", { status: 429 });
}

Secure Headers

Configure security headers in next.config.js:

const securityHeaders = [
  {
    key: "X-Frame-Options",
    value: "DENY",
  },
  {
    key: "X-Content-Type-Options",
    value: "nosniff",
  },
  {
    key: "Referrer-Policy",
    value: "strict-origin-when-cross-origin",
  },
];

CSRF Protection

Auth.js includes CSRF protection by default. Never disable it:

// Auth.js automatically:
// - Generates CSRF tokens
// - Validates tokens on mutations
// - Uses SameSite cookies

User Experience Tips

1. Clear Error Messages

// Bad
"Authentication failed"

// Good
"We couldn't find an account with that email. Would you like to create one?"

2. Remember User's Email

Store the email in localStorage after successful sign-in for future attempts:

localStorage.setItem("lastUsedEmail", email);

3. Show Loading States

Authentication can take time. Always show feedback:

<Button disabled={isLoading}>
  {isLoading ? "Signing in..." : "Sign in"}
</Button>

4. Handle Email Delivery Delays

Magic links may take time to arrive:

<p className="text-muted-foreground">
  Check your inbox for a sign-in link.
  It may take a few minutes to arrive.
  Check your spam folder if you don't see it.
</p>

Conclusion

Authentication is a solved problem, but the details matter. Use Auth.js for the heavy lifting, implement magic links for the best user experience, and add OAuth for convenience. Most importantly, always prioritize security without sacrificing usability.

Remember: the best authentication system is one users barely notice because it just works.

Newsletter

Stay in the loop

Get the latest articles, tutorials, and product updates delivered straight to your inbox. No spam, unsubscribe anytime.

Join 1,000+ developers already subscribed

Related Articles