NestJS + Angular Authentication: JWT, OAuth, and Refresh Tokens
Auth Is the First Feature, and the Hardest to Get Right
Every SaaS needs authentication. It's also where most security vulnerabilities live. This guide covers a production-ready auth system using NestJS and Angular — the same approach used in SaaS Starter.
Architecture Overview
The auth system has four layers:
- JWT access tokens — Short-lived (15 min), sent in Authorization headers
- Refresh tokens — Long-lived (7 days), stored in HTTP-only cookies
- Google OAuth — Social login via Passport.js
- Angular guards — Route protection on the frontend
Cloudrix SaaS Starter ships with this pre-configured and tested.
Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.
Try the live demo →Step 1: JWT Strategy (NestJS)
Install Passport and JWT packages:
npm install @nestjs/passport @nestjs/jwt passport passport-jwt
Create the JWT strategy:
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
ignoreExpiration: false,
});
}
async validate(payload: JwtPayload) {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
The key decisions here: extract from the Authorization header (not cookies — that opens CSRF issues), validate the payload structure, and attach the user object to the request.
Step 2: Refresh Token Rotation
Access tokens expire in 15 minutes. Refresh tokens let users stay logged in without re-entering credentials. The critical security pattern is rotation — every time a refresh token is used, issue a new one and invalidate the old one:
@Injectable()
export class AuthService {
async refreshTokens(userId: string, refreshToken: string) {
const user = await this.userService.findById(userId);
if (!user || !user.hashedRefreshToken) {
throw new ForbiddenException('Access denied');
}
const tokenMatches = await bcrypt.compare(
refreshToken,
user.hashedRefreshToken,
);
if (!tokenMatches) throw new ForbiddenException('Access denied');
const tokens = await this.generateTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refreshToken);
return tokens;
}
private async generateTokens(userId: string, email: string) {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ sub: userId, email },
{ secret: this.config.get('JWT_SECRET'), expiresIn: '15m' },
),
this.jwtService.signAsync(
{ sub: userId, email },
{ secret: this.config.get('JWT_REFRESH_SECRET'), expiresIn: '7d' },
),
]);
return { accessToken, refreshToken };
}
}
Why rotation matters: If a refresh token is stolen, the attacker can use it once. When the legitimate user's next refresh fails (because the token was already rotated), you know the token was compromised and can invalidate all sessions.
Step 3: Google OAuth
Add Google OAuth with Passport:
npm install passport-google-oauth20
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private config: ConfigService) {
super({
clientID: config.get('GOOGLE_CLIENT_ID'),
clientSecret: config.get('GOOGLE_CLIENT_SECRET'),
callbackURL: config.get('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
async validate(accessToken: string, refreshToken: string, profile: any) {
return {
email: profile.emails[0].value,
name: profile.displayName,
googleId: profile.id,
};
}
}
The OAuth controller handles the redirect flow:
@Controller('auth')
export class AuthController {
@Get('google')
@UseGuards(AuthGuard('google'))
googleLogin() {}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req, @Res() res: Response) {
const tokens = await this.authService.googleLogin(req.user);
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
res.redirect(`${this.config.get('APP_URL')}/auth/callback?token=${tokens.accessToken}`);
}
}
Step 4: Angular Auth Guard
On the frontend, protect routes with a functional guard:
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
router.navigate(['/login']);
return false;
};
Step 5: Angular HTTP Interceptor
Attach the access token to every API request and handle token refresh automatically:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getAccessToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return authService.refreshToken().pipe(
switchMap((newToken) => {
req = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` },
});
return next(req);
}),
);
}
return throwError(() => error);
}),
);
};
This interceptor transparently refreshes expired tokens. Users never see a login screen mid-session.
Security Checklist
See how SaaS Starter implements all of this in the authentication feature page.
- Store refresh tokens in HTTP-only, secure, SameSite cookies
- Hash refresh tokens in the database (never store plaintext)
- Rotate refresh tokens on every use
- Set short access token expiry (15 minutes)
- Validate email ownership before linking OAuth accounts
- Rate-limit login and refresh endpoints
Skip the Implementation
The auth system above takes 1-2 weeks to build, test, and harden. SaaS Starter Pro ships it fully implemented — JWT, Google OAuth, refresh token rotation, Angular guards, interceptors, and the security hardening you'd otherwise forget.
Get Started Today
All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup — auth, payments, multi-tenancy, deployment all done.
- Free version: GitHub
- Pro ($249): Get SaaS Starter Pro