Authentication Best Practices for Next.js SaaS Apps in 2026
A practical guide to implementing secure authentication in Next.js SaaS applications. Covers JWT with refresh tokens, OAuth, email verification, and role-based access control.
Authentication is the first thing your users interact with and the last thing you want to get wrong. A poorly implemented auth system leaks data, frustrates users, and creates security vulnerabilities that compound over time.
This guide covers the authentication patterns that work best for Next.js SaaS applications in 2026: JWT with refresh token rotation, OAuth integration, email verification, and role-based access control.
JWT Authentication with Refresh Tokens
JSON Web Tokens remain the standard for SaaS authentication. They're stateless, work well across microservices, and integrate naturally with both server-rendered and client-side Next.js applications.
Why JWT Over Sessions
Session-based authentication stores state on the server. Every request requires a database lookup to validate the session. This works fine for monolithic applications, but introduces complexity when you have multiple services, edge functions, or need to scale horizontally.
JWTs encode user identity and permissions directly in the token. Any service can validate a JWT without hitting a database — it just needs the signing key.
The Access + Refresh Token Pattern
A single long-lived JWT is a security risk. If it's stolen, the attacker has access until the token expires. Short-lived tokens are more secure but force users to log in frequently.
The solution is two tokens:
- Access token — short-lived (15–30 minutes). Used for API requests. Contains user ID, roles, and permissions.
- Refresh token — long-lived (7–30 days). Stored securely. Used only to obtain new access tokens.
The flow works like this:
- User logs in with email/password or OAuth.
- Server issues an access token and a refresh token.
- Client sends the access token with every API request.
- When the access token expires, the client sends the refresh token to get a new pair.
- If the refresh token is expired or revoked, the user must log in again.
Refresh Token Rotation
Refresh token rotation adds another layer of security. Each time a refresh token is used, the server issues a new refresh token and invalidates the old one.
If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (because the token was already rotated). This alerts the system to a potential compromise, and all tokens for that user can be revoked.
async refreshTokens(refreshToken: string) {
const payload = this.jwtService.verify(refreshToken);
const storedToken = await this.tokenRepository.findOne({
where: { token: refreshToken, userId: payload.sub },
});
if (!storedToken) {
// Token reuse detected — revoke all tokens for this user
await this.tokenRepository.delete({ userId: payload.sub });
throw new UnauthorizedException('Token reuse detected');
}
// Rotate: delete old token, issue new pair
await this.tokenRepository.delete({ id: storedToken.id });
return this.issueTokenPair(payload.sub);
}Secure Token Storage
Where you store tokens matters:
- Access tokens — in memory (JavaScript variable). Never in localStorage or sessionStorage. In-memory storage means tokens are lost on page refresh, but the refresh token handles re-authentication.
- Refresh tokens — in an httpOnly, secure, sameSite cookie. This makes the token inaccessible to JavaScript, protecting against XSS attacks.
response.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});OAuth Integration
Users expect to sign in with Google and GitHub. OAuth simplifies the login experience and reduces friction, but the implementation has nuances.
The OAuth Flow for SaaS
- User clicks "Sign in with Google."
- Your app redirects to Google's OAuth consent screen.
- User authorizes your app.
- Google redirects back to your app with an authorization code.
- Your backend exchanges the code for an access token.
- Your backend fetches the user's profile from Google.
- Your backend creates or links the user account and issues your own JWT tokens.
The key detail: always exchange the authorization code on your backend, never on the client. Client-side token exchange exposes your OAuth client secret.
Account Linking
Users who sign up with email/password might later want to link their Google account (or vice versa). Handle this by matching on verified email address:
async handleOAuthLogin(profile: OAuthProfile) {
let user = await this.userRepository.findOne({
where: { email: profile.email },
});
if (user) {
// Link OAuth provider to existing account
await this.oauthAccountRepository.save({
userId: user.id,
provider: profile.provider,
providerId: profile.providerId,
});
} else {
// Create new user with OAuth account
user = await this.userRepository.save({
email: profile.email,
name: profile.name,
emailVerified: true, // OAuth emails are pre-verified
});
}
return this.issueTokenPair(user.id);
}Important: only link accounts when the OAuth provider has verified the email address. An unverified OAuth email could be used to hijack an existing account.
Supported Providers
At minimum, support Google and GitHub. Google covers the vast majority of business users. GitHub covers developers — particularly relevant if you're building a developer tool.
Adding more providers (Microsoft, Apple, Twitter) increases coverage but also increases maintenance. Start with two and add more based on user demand.
Email Verification
Email verification confirms that users own the email address they registered with. It's essential for account security and for sending transactional emails.
Implementation
- When a user registers, generate a secure, random token and store it with an expiration (24 hours).
- Send an email with a verification link containing the token.
- When the user clicks the link, validate the token and mark the email as verified.
- Restrict access to certain features until email is verified.
async sendVerificationEmail(userId: string) {
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await this.verificationTokenRepository.save({
userId,
token: await bcrypt.hash(token, 10),
expiresAt: expires,
});
await this.emailService.send({
to: user.email,
subject: 'Verify your email',
link: `${this.appUrl}/verify-email?token=${token}`,
});
}Hash the verification token before storing it. If your database is compromised, raw tokens in the verification table would allow an attacker to verify any account.
Rate Limiting
Rate limit verification email requests to prevent abuse. A reasonable limit is 3 requests per email address per hour. Without rate limiting, an attacker could trigger thousands of emails from your domain, damaging your sender reputation.
Role-Based Access Control
SaaS applications need permissions at two levels: application-level (regular user vs. super admin) and organization-level (owner, admin, member).
Application Roles
- User — standard access. Can use the product within their organizations.
- Super Admin — full access. Can view all organizations, impersonate users, and manage the platform.
Organization Roles
- Owner — full control over the organization. Can delete the org, manage billing, and transfer ownership.
- Admin — can manage members, update settings, but can't delete the org or change billing.
- Member — standard access within the organization. Can use features but can't manage settings or members.
Implementing RBAC with Guards
In a NestJS backend, use guards to enforce permissions:
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(OrganizationRole.ADMIN)
@Post('organizations/:orgId/members')
async inviteMember(
@Param('orgId') orgId: string,
@Body() dto: InviteMemberDto,
) {
return this.membersService.invite(orgId, dto);
}In Next.js middleware, check permissions before rendering pages:
export async function middleware(request: NextRequest) {
const token = request.cookies.get('access_token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = await verifyToken(token.value);
if (request.nextUrl.pathname.startsWith('/admin') &&
payload.role !== 'super_admin') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}Permission Granularity
Start simple. Three organization roles (owner, admin, member) cover 90% of SaaS use cases. Don't build a full permission system with custom roles and granular resource-level permissions unless your product actually needs it. You can always add granularity later.
Security Checklist
Before you ship your authentication system, verify these:
- Access tokens expire in 15–30 minutes
- Refresh tokens are stored in httpOnly, secure cookies
- Refresh token rotation is implemented with reuse detection
- OAuth tokens are exchanged on the backend, never the client
- Passwords are hashed with bcrypt (cost factor 10+)
- Verification tokens are hashed before storage
- Rate limiting is applied to login, registration, and password reset endpoints
- CORS is configured to allow only your frontend domain
- CSRF protection is enabled for cookie-based authentication
- All auth endpoints use HTTPS in production
Skip the Implementation, Ship the Product
Authentication is critical infrastructure, but it's not your product. Every hour spent building auth is an hour not spent on the features your users are paying for.
SaasSeed implements all the patterns described in this article — JWT with refresh rotation, Google and GitHub OAuth, email verification, and role-based access control — with 800+ tests ensuring everything works correctly.
Get SaasSeed → and start building what actually matters.
