End-to-end Guide to building a Reliable Authentication System for a Startup MVP - Part 1
As a startup, you’re always racing against time. Speed is crucial—you need to build, launch, and iterate quickly. That’s why many startups opt to outsource their authentication systems to third-party providers like Auth0, Firebase, or Supabase. These solutions are robust, secure, and save you the hassle of managing tokens and user sessions. But as your user base grows, so can the costs—often to the point where it no longer makes sense for a small or medium-sized business.
Here’s the thing: building an authentication system from scratch isn’t as scary as it sounds. I’ve been down this road before. A few years back, I led a backend team for a startup where we initially chose Auth0 for its seamless integration and features. But as we got closer to launch, the projected costs became a dealbreaker. So, we decided to roll up our sleeves and build our own system. We focused on simplicity, security, and scalability, sticking to best practices. Since then, I’ve made it a point to minimize reliance on SaaS services, especially for MVPs.
In this series, I’m going to walk you through how to build an authentication system using NestJS (with the Express adapter, you can also use the Fastify adapter for better performance), JWT, PassportJS, and a few other open-source tools. Don’t worry—it’s easier than you might think, and by the end, you’ll have a system that can handle everything from local sign-ins to social logins like Google and GitHub. This first part will contain majority of the foundational code.
This system can be integrated into any client, whether it's a web app, mobile app, or desktop application. For this tutorial, we will use a simple React app scaffold with Vite. Here’s what you can expect to learn in this series:
Local authentication — username/email and password;
OAuth integration — Google, Github and X (formerly Twitter);
JWT for secure authentication claims;
HTTP-only cookies for persistent user sessions;
PrismaORM for efficient database connections and queries;
Shadcn and TailwindCSS for sleek and responsive UI design.
Get ready to dive in and build something amazing!
All the code can be accessed here on Github.
Here's how the backend folder structure looks:
├── .env.example
├── src
│ ├── auth
│ │ ├── auth.module.ts
│ │ ├── auth.types.ts
│ │ ├── controllers
│ │ │ ├── facebook-auth.controller.ts
│ │ │ ├── github-auth.controller.ts
│ │ │ ├── google-auth.controller.ts
│ │ │ └── local-auth.controller.ts
│ │ ├── dto
│ │ │ ├── signin.dto.ts
│ │ │ └── signup.dto.ts
│ │ ├── jwt-auth.guard.ts
│ │ ├── services
│ │ │ ├── auth.service.ts
│ │ │ └── token.service.ts
│ │ └── strategies
│ │ ├── facebook.strategy.ts
│ │ ├── github.strategy.ts
│ │ ├── google.strategy.ts
│ │ └── local.strategy.ts
│ ├── config
│ │ └── index.ts
│ ├── libs
│ │ ├── errors
│ │ │ ├── api-error.ts
│ │ │ └── bad-request.error.ts
│ │ └── prisma
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── main.ts
│ └── utils
│ ├── assert-defined.ts
│ └── constants.ts
Setting Up the Backend
Follow this guide on Nestjs website to bootstrap a new project.
Inside main.ts
, there are two important configurations:
const configService = app.get(ConfigService);
const cookieSecret = configService.get<string>('cookie_secret');
// You can add more secrets for rotation
app.use(cookieParser([cookieSecret]));
const clientOrigin = configService.get<string>('foontend_client_origin');
app.enableCors({
origin: [clientOrigin],
credentials: true,
maxAge: 86400,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Authorization'],
});
cookieParser
takes an optionalsecret
, which can be a string or an array used for signing cookies. It also accepts an optionalcookieParser.CookieParseOptions
as a second argument. You can read more here;For
CORS
,origin
can be a string or an array of strings. Ensure it only contains the client origins you want to grant access to;you need to also set
credential: true
, it configures the Access-Control-Allow-Credentials CORS header, this means that it should allow the client (browser) to sent cookies with the request headers.
Defining the User Model
In Prisma, our user model looks something like this:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// --------------- User model start --------------------
model User {
id String @id @default(cuid())
email String @unique
facebookId String? @unique
googleId String? @unique
githubId String? @unique
password String?
// You can add other user data you want here
firstName String?
lastName String?
}
// --------------- User model end -----------------------
This model is straightforward and flexible. It supports local sign-ins and social logins via Facebook, Google, and GitHub.
We are using sqlite
, but you can simply switch that to any database provider. The social sign-on IDs are unique and nullable. Note that not all databases support unique nullable fields; for example, this doesn't work by default with the MongoDB provider. You might want to look into Partial Index. To create a new migration to sync the User model with the database schema, use the following command:
To sync this model with your database, run:
$ npx prisma migrate dev
And generate your Prisma client:
$ npx prisma generate
AuthService and TokenService
The AuthService and TokenService handle the core authentication logic. Let’s break it down:
AuthService handles user creation and validation:
The
auth.service.ts
handles creating and validation;token.service.ts
is responsible for generating tokens and setting cookies to identify the user.
Inside TokenService
:
@Injectable()
export class TokenService {
constructor(
private readonly jwt: JwtService,
private readonly configService: ConfigService,
) {}
private getBaseOptions(): JwtSignOptions {
return {
issuer: this.configService.getOrThrow<string>('jwt_issuer'),
audience: [this.configService.getOrThrow<string>('jwt_audience')],
header: {
alg: 'HS256',
},
};
}
private async generateAccessToken(user: User): Promise<string> {
const signOptions: JwtSignOptions = {
...this.getBaseOptions(),
expiresIn: this.configService.getOrThrow<string>(
'access_token_expires_in',
),
secret: this.configService.getOrThrow<string>('access_token_secret'),
subject: user.id,
};
return this.jwt.signAsync(
{
userId: user.id,
},
signOptions,
);
}
private async generateRefreshToken(user: User): Promise<string> {
const signOptions: JwtSignOptions = {
...this.getBaseOptions(),
expiresIn: this.configService.getOrThrow<string>(
'refresh_token_expires_in',
),
secret: this.configService.getOrThrow<string>('refresh_token_secret'),
subject: user.id,
};
return await this.jwt.signAsync(
{
userId: user.id,
},
signOptions,
);
}
async setAuthCookies(res: Response, user: User) {
const cookieOptions = {
httpOnly: true,
secure: IS_PROD,
sameSite: 'lax',
path: '/',
domain: IS_PROD
? `.${this.configService.getOrThrow<string>('domain')}`
: '',
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 year
} as const;
const accessToken = await this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user);
res
.cookie(ACCESS_TOKEN_COOKIE_ID, `Bearer ${accessToken}`, cookieOptions)
.cookie(REFRESH_TOKEN_COOKIE_ID, `Bearer ${refreshToken}`, cookieOptions);
}
deleteAuthCookies(res: Response) {
res
.clearCookie(ACCESS_TOKEN_COOKIE_ID)
.clearCookie(REFRESH_TOKEN_COOKIE_ID);
}
}
In
getBaseOption()
, we have defined the signing algorithm to be HS256 because it is a secure hash algorithm that is trusted and widely used in many different security protocols.audience
is a string or an array of client origins (it’s optional).issuer
is your server’s origin, it’s optional too.In
generateAccessToken()
, we've made sure to sign the token with theACCESS_TOKEN_SECRET
in our .env variable. Ensure this secret is a very long and complex string of characters, at least 32 characters long. We use this secret to validate the token. We have set theACCESS_TOKEN_EXPIRES_IN
variable, which should be a short time depending on your application. It can be 30 seconds, 1 minute, or 30 minutes, etc. The main idea here is that it is short-lived.The
ACCESS_TOKEN_EXPIRES_IN
is the minimum time you want the app to query the database and issue new access and refresh tokens. You will later see this in action insidejwt-auth.guard.ts
.We also store minimal information in the payload because data stored in the JWT payload is public. It is important to exercise caution and only store non-sensitive user data. In our case, we only store the
userId
.The same goes for
generateRefreshToken()
, but the refresh token can be long-lived. You can set it to 1 month, 3 months, 6 months, or 1 year, etc., again, depending on your application needs.
We expose only one public method, which is setAuthCookies()
, where we call our token generation methods and set those tokens to the response cookies. The following are very crucial in our cookieOptions
config:
We made our cookie
httpOnly: true
, meaning the cookies cannot be accessed via JavaScript in the browser. This mitigates attacks against cross-site scripting (XSS).sameSite
is a security measure that helps prevent Cross-Site Request Forgery (CSRF) attacks.secure: true
ensures the cookies are only sent in HTTPS requests.domain
was only set in production because we don’t have a real domain on localhost.maxAge
depends on how long you want the browser to store the cookies; we set it to 1 year.
You can read more on cookie options here.
Inside AuthService
:
@Injectable()
export class AuthService {
constructor(
private prismaService: PrismaService,
private configService: ConfigService,
) {}
async createUser(data: SignUpDto) {
const salt = await genSalt(
this.configService.getOrThrow<number>('password_salt'),
);
const hashedPassword = await hash(data.password, salt);
const input = { ...data };
input.password = hashedPassword;
try {
const user = await this.prismaService.user.create({
data: input,
});
delete user.password;
return user;
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
throw BadRequestError('email', 'User with this email already exists');
}
}
throw new Error(e);
}
}
async validateUserSignIn(data: SignInDto) {
let user: User;
try {
user = await this.prismaService.user.findUniqueOrThrow({
where: {
email: data.email,
},
});
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw BadRequestError('email', 'User not found');
}
}
throw new Error(e);
}
const valid = await this.validatePasswords(user.password, data.password);
if (!valid) {
throw BadRequestError('email', 'Login credentials are invalid');
}
return user;
}
async findUserById(userId: string) {
try {
const user = await this.prismaService.user.findUnique({
where: {
id: userId,
},
});
delete user.password;
return user;
} catch (e) {
return null;
}
}
private async validatePasswords(
authPassword: string,
password: string,
): Promise<boolean> {
const isMatch = await compare(password, authPassword).then((same) => same);
return isMatch;
}
}
createUser()
was used to create a new user during the sign-up flow. You will notice that I didn't check if there's an existing user with that email, but I am gracefully catching this error in the catch block to avoid making an extra query.validateUserLogin()
was used to find and validate user's login credentials during local sign-in;findOAuthUserOrCreate()
was used to find a user or create a new user during OAuth flows. This method tries to find an existing user with the OAuth user's email or ID. If it doesn't find one, it creates a new user. This method is used for both sign-up and sign-in. If you want different behavior for both, for example, you might want to disallow signing in the user if the OAuth user doesn't exist in the database. Additionally, the method automatically links the user if the user exists but doesn't have the OAuth provider set. This all depends on your application and the behavior you want.
Local authentication routes:
Inside the of controllers/local-auth.controller.ts, we defined two routes for local authentication:
Sign-up:
POST /api/v1/local-auth/signup-with-password
Sign-in:
POST /api/v1/local-auth/signin-with-password
You can test on Postman:
// Create new user - sign up
{
"email": "test-user@gmail.com",
"firstName": "Test",
"lastName": "User1",
"password": "Test1234"
}
// Sign in
{
"email": "test-user@gmail.com",
"password": "Test1234"
}
Protected routes: AuthGuard
To protect your routes, we use a JWT-based guard:
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private tokenService: TokenService,
private authService: AuthService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const { accessToken, refreshToken } =
this.extractTokensFromCookieOrHeader(request);
if (!accessToken) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync<JwtTokenPayload>(
accessToken,
{
secret: this.configService.getOrThrow('access_token_secret'),
},
);
request['user'] = payload;
return true;
} catch {
// token is expired
}
if (!refreshToken) {
throw new UnauthorizedException();
}
let data: JwtTokenPayload;
try {
data = await this.jwtService.verifyAsync<JwtTokenPayload>(refreshToken, {
secret: this.configService.getOrThrow('refresh_token_secret'),
});
} catch {
throw new UnauthorizedException();
}
const user = await this.authService.findUserById(data.userId);
if (!user) {
throw new UnauthorizedException();
}
// Set new tokens since accessToken has expired
await this.tokenService.setAuthCookies(response, user);
request['user'] = data;
return true;
}
private extractTokensFromCookieOrHeader(request: Request): TokensType {
let tokens: TokensType = this.extractTokenFromCookies(request);
if (!tokens.accessToken && !tokens.refreshToken) {
tokens = this.extractTokensFromHeader(request);
}
return tokens;
}
private extractTokenFromCookies(request: Request): TokensType {
const aCookie = request.cookies[ACCESS_TOKEN_COOKIE_ID];
const [aType, aToken] = aCookie?.split(' ') ?? [];
const accessToken = aType === 'Bearer' ? aToken : undefined;
const rCookie = request.cookies[REFRESH_TOKEN_COOKIE_ID];
const [rType, rToken] = rCookie?.split(' ') ?? [];
const refreshToken = rType === 'Bearer' ? rToken : undefined;
return {
accessToken,
refreshToken,
};
}
private extractTokensFromHeader(request: Request): TokensType {
const authHeader = request.headers.authorization;
if (!authHeader) {
return {};
}
const [bearer, tokens] = authHeader.split(' ');
if (bearer !== 'Bearer' || !tokens) {
return {};
}
const [accessToken, refreshToken] = tokens.split(',');
return {
accessToken,
refreshToken,
};
}
}
In jwt-auth.guard.ts
, we basically created a guard that implements CanActivate, and we return true if the user's token is validated or false if the user's token cannot be validated. This is where your JwtSignOptions.expiresIn
comes into play, inside our JwtAuthGuard
:
extractTokensFromCookieOrHeader()
method: we extract the token from either the Authorizationheaders
or from thecookies
. This is because we want our server to be flexible enough in decoding the authorization token. For example, a mobile app can send the tokens via headers like this:'Authorization': `Bearer ${accessToken},${refreshToken}`
- A web app can send it via cookies or authorization headers.
We have overridden the
canActivate()
method to let the user continue if the accessToken is valid. If the accessToken has expired, we check the refreshToken. If the refreshToken is valid, we create new tokens and let the user continue.
We can use this JwtAuthGuard
to check if the user is authenticated, like this:
@UseGuards(JwtAuthGuard)
@Get('/me')
public async getUser(@Req() req: RequestWithAuthUser) {
const userId = req?.user?.userId || '';
const user = await this.authService.findUserById(userId);
return {
status: 'success',
data: user,
meta: null,
};
}
Frontend Integration
The auth-react app includes:
Home page
/
which is a protected route. We call the/api/v1/local-auth/me
endpoint. If it returns a user, we are authenticated; if not, the user is not logged in.We have a React Context provider that wraps the whole app tree, where we check the user's authentication status.
export const AuthProvider = ({ children }: { children?: React.ReactNode }) => { const { logout } = useLogout(); const login = useLogin(); const signup = useSignup(); const { data, isLoading } = useMe(); const values = useMemo( () => ({ user: data, isLoading: isLoading || login.isLoading || signup.isLoading, logout, login, signup, }), [data, isLoading, login, logout, signup] ); return <AuthContext.Provider value={values}>{children}</AuthContext.Provider>; };
We used
@tanstack/react-query
for querying the data, and insideuseMe
, we set astaleTime
which refetches the user every 15 minutes.export const useMe = () => { const { data, isPending } = useQuery({ queryKey: [QueryKeyEnums.ME], queryFn: UserServices.me, staleTime: 15 * 60 * 1000, // 15 minutes in milliseconds retry: 0, }); // If there's a user, they are authenticated return { data: data?.data.data, isLoading: isPending }; };
Inside
main.tsx
, we make sure to set defaults for axios:axios.defaults.withCredentials = true; axios.defaults.baseURL = BASE_API_URL;
withCredentials
makes sure the browser cookies are attached to the requests;with
baseURL
we don't need to import BASE_API_URL in any of our files;
We used
zod
for form validation, andreact-hook-form
for performant forms.
Majority of the frontend UI components was generated using v0 by Vercel, a great tool.
Again, all the code can be accessed here on Github.
Wrapping Up
With this guide, we have built a functional authentication system that is flexible, cost-effective, and go to production ready for your start-up MVP.
Start simple and iterate as your application grows. Building your own system gives you flexibility.
In further exploration, we will:
Scalability, Performance & Security - Rate limiting, Queue system, Postgres DB & Fastify;
Integrate APIs in mobile app - React Native.
Let me know what you would like me to talk about, you can reach me here taiwo.ogunola.dev@gmail.com. You can also find me here on LinkedIn.
Subscribe to my newsletter
Read articles from Taiwo Ogunola directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Taiwo Ogunola
Taiwo Ogunola
With over 5 years of experience in delivering technical solutions, I specialize in web page performance, scalability, and developer experience. My passion lies in crafting elegant solutions to complex problems, leveraging a diverse expertise in web technologies.