Introduction
In Week 10 Task 1, I connected a NestJS Tasks API to PostgreSQL using
TypeORM. The API could create users and tasks, but it did not know who
was making a request. Anyone who could reach the task endpoints could
access the available task data.
Task 2 builds on the same idea and adds authentication and user-level
authorization.
I created a separate runnable project so the first task remains
unchanged:
week-10/Task-02/nest-jwt-auth-api/
The goal is simple: users should register, log in, receive a JWT access
token, and manage only their own tasks.
What this API does
- Register a new account.
- Hash passwords before saving them.
- Log in using email and password.
- Issue a signed JWT access token.
- Verify bearer tokens using Passport and a JWT strategy.
- Protect all task routes with a NestJS guard.
- Scope every task operation to the authenticated user's ID.
- Validate request bodies using DTOs and a global
ValidationPipe.
The project intentionally does not include refresh tokens, password
reset, email verification, or rate limiting.
Tech stack
- NestJS 11 -- application structure, modules, guards, and dependency injection.
- TypeScript -- application code and types.
- PostgreSQL -- persistent storage.
- TypeORM 0.3 -- database mapping and queries.
- JWT -- signed access tokens.
- Passport / passport-jwt -- bearer-token authentication strategy.
- bcrypt -- password hashing and verification.
- class-validator -- DTO validation.
- ConfigModule -- environment-based configuration.
- Jest -- service-level tests.
1. Authentication vs authorization
These two terms are related, but they solve different problems.
Authentication answers: "Who are you?"
For this API, a user proves their identity by providing their email and
password. If those credentials are correct, the API issues a JWT access
token.
Authorization answers: "What are you allowed to access?"
After authentication, the API uses the verified user's ID to decide
which tasks they can read, create, update, or delete.
For example, logging in as User A should not allow access to User B's
tasks. That is why this project does more than just check whether a
token exists: it also scopes database operations to the authenticated
user.
2. Registration and DTO validation
Files:
-
src/auth/dto/register.dto.ts -
src/auth/dto/login.dto.ts -
src/main.ts
A DTO (Data Transfer Object) defines the shape of data accepted by an
endpoint.
The registration DTO uses decorators from class-validator:
export class RegisterDto { @IsString() @MinLength(2) @MaxLength(100) name!: string; @IsEmail() @MaxLength(180) email!: string; @IsString() @MinLength(8) @MaxLength(72) password!: string; } This means the incoming name, email, and password must satisfy the
declared rules before the controller receives the validated request.
The login DTO validates the email and password fields too.
Global ValidationPipe
In src/main.ts, the application configures:
app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); These options are useful for API input handling:
-
whitelist: trueremoves properties that do not have validation decorators. -
forbidNonWhitelisted: truerejects requests containing unexpected properties instead of silently accepting them. -
transform: trueenables transformation of incoming payloads into DTO instances and can convert compatible values.
For example, a client cannot add an arbitrary isAdmin property to the
registration DTO and expect it to be accepted.
3. Password hashing with bcrypt
Files:
-
src/users/users.service.ts -
src/users/entities/user.entity.ts
Passwords should never be stored as plain text.
During registration, UsersService.create() hashes the password:
const passwordHash = await bcrypt.hash(input.password, 12); The value 12 is the bcrypt cost factor (salt rounds). bcrypt generates
a salted password hash, which is what the database stores.
The user entity maps that value to the password_hash column:
@Column({ name: 'password_hash', type: 'varchar', length: 255, select: false, }) passwordHash!: string; The select: false option means TypeORM does not include the password
hash in ordinary queries by default.
When login needs to verify a password, findByEmailWithPassword()
explicitly selects the hash:
return this.users .createQueryBuilder('user') .addSelect('user.passwordHash') .where('LOWER(user.email) = LOWER(:email)', { email: email.trim(), }) .getOne(); Then the login flow compares the submitted password with the stored
hash:
const valid = user ? await bcrypt.compare(input.password, user.passwordHash) : false; bcrypt does not decrypt the stored hash. It hashes the submitted
password using the information embedded in the stored bcrypt hash and
checks whether the result matches.
The API returns a safe user object containing only the ID, name, and
email. It never returns the password hash.
Duplicate email handling
The email column has a database-level unique constraint. If PostgreSQL
reports a unique-constraint violation (23505), UsersService converts
it into a ConflictException, which gives the client an HTTP 409
response.
The database constraint matters because application-level checks alone
cannot reliably prevent two concurrent requests from registering the
same email.
4. Registration and login flow
Files:
-
src/auth/auth.controller.ts -
src/auth/auth.service.ts -
src/users/users.service.ts
The controller exposes two public routes:
Method Route Purpose
POST /auth/register Create an account
POST /auth/login Verify credentials and issue a token
The controller delegates the work to AuthService.
Registration flow
- The client sends name, email, and password.
-
ValidationPipevalidates the request DTO. -
AuthControllercallsAuthService.register(). -
AuthServiceasksUsersServiceto create the user. -
UsersServicetrims the name, normalizes the email to lowercase, hashes the password, and saves the record. -
AuthServicecreates a JWT. - The API returns the safe user object and access token.
Login flow
- The client sends email and password.
- The DTO is validated.
-
AuthServiceasksUsersServiceto find the account and explicitly load its password hash. - bcrypt compares the submitted password with the stored hash.
- If the account is missing or the password does not match, the API throws
UnauthorizedException(HTTP 401). - If credentials are valid, the API signs a JWT and returns it with the safe user object.
The error message is deliberately generic: Invalid email or password.
It does not reveal whether the email address exists.
5. What is a JWT?
JWT stands for JSON Web Token. It is a compact, signed token that
can carry claims about a user.
A JWT commonly has three parts separated by dots:
header.payload.signature - Header -- describes the token type and signing algorithm.
- Payload -- contains claims such as the subject (
sub) and email. - Signature -- allows the server to verify that the token was signed with the expected secret and has not been modified.
In this project, the token payload is created using:
return this.jwt.signAsync({ sub: userId, email }); The sub claim means subject. Here it identifies the authenticated user
by their database ID.
The JWT secret and expiration duration are loaded from environment
variables through ConfigService.
Important: a signed JWT is not automatically encrypted. Do not put
passwords, password hashes, or other secrets in its payload. A client
holding the token can generally decode and read the payload.
6. Configuring JWT in NestJS
File: src/auth/auth.module.ts
The module configures JwtModule asynchronously:
JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ secret: config.getOrThrow<string>('JWT_SECRET'), signOptions: { expiresIn: config.get<string>('JWT_EXPIRES_IN') ?? '1h', }, }), }) The .env.example documents these variables:
JWT_SECRET=replace_with_a_long_random_secret_at_least_32_characters JWT_EXPIRES_IN=1h The actual secret belongs in a local or deployment environment variable,
not in committed source code. It should be long, random, and kept
private.
The token expires after the configured duration. In this example, the
default is one hour.
7. Passport JWT strategy: verifying the token
File: src/auth/strategies/jwt.strategy.ts
Issuing a token is only half of authentication. For protected routes,
the server must verify the token sent by the client.
The strategy is configured to:
- Extract the JWT from the
Authorization: Bearer <token>header. - Verify the signature using
JWT_SECRET. - Reject expired tokens.
- Validate the expected payload fields.
The important configuration is:
super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: config.getOrThrow<string>('JWT_SECRET'), }); Passport extracts the token from the bearer header and verifies it. If
the token is missing, invalid, or expired, authentication fails.
After successful verification, Passport calls the strategy's
validate() method:
validate(payload: JwtPayload) { if (!payload.sub || !payload.email) { throw new UnauthorizedException('Invalid access token'); } return { userId: payload.sub, email: payload.email, }; } The object returned from validate() becomes request.user. The rest
of the application can use this trusted identity rather than accepting a
user ID from the request body.
8. JwtAuthGuard: protecting routes
File: src/auth/guards/jwt-auth.guard.ts
@Injectable() export class JwtAuthGuard extends AuthGuard('jwt') {} This guard uses Passport's registered jwt strategy.
The Tasks controller applies it at the controller level:
@Controller('tasks') @UseGuards(JwtAuthGuard) export class TasksController { // task endpoints } Because the guard is attached to the whole controller, all task routes
require a valid JWT.
If a request has no bearer token, an invalid token, or an expired token,
the request is rejected with HTTP 401 before the controller handler
runs.
The registration and login routes are not protected by this guard
because they are public entry points for obtaining an access token.
9. CurrentUser decorator: getting the authenticated identity
Files:
-
src/auth/decorators/current-user.decorator.ts -
src/auth/interfaces/authenticated-user.interface.ts -
src/tasks/tasks.controller.ts
After Passport verifies a token, the authenticated identity is available
as request.user.
The custom @CurrentUser() decorator extracts that value:
export const CurrentUser = createParamDecorator( (_data: unknown, context: ExecutionContext): AuthenticatedUser => context.switchToHttp() .getRequest<{ user: AuthenticatedUser }>() .user, ); A task endpoint can then receive the authenticated user directly:
@Get() findAll(@CurrentUser() user: AuthenticatedUser) { return this.tasks.findAll(user.userId); } This keeps the controller clean and avoids manually reading
request.user in every route.
The important security point is that the user ID comes from the verified
token, not from a client-supplied userId.
10. Authorization: ensuring users only access their own tasks
This is the most important part of Task 2.
Authentication proves who is making the request. Authorization ensures
that the user can access only their own data.
Files:
-
src/tasks/tasks.controller.ts -
src/tasks/tasks.service.ts -
src/tasks/repositories/tasks.repository.ts -
src/tasks/repositories/typeorm-tasks.repository.ts
The controller passes the verified user's ID to the service. The service
passes that ID to repository methods such as:
-
findAllForUser(userId) -
findOneForUser(id, userId) -
createForUser(input, userId) -
updateForUser(id, userId, updates) -
removeForUser(id, userId)
The repository applies the ownership condition in the database query
itself.
For example, retrieving one task uses both the task ID and authenticated
user ID:
findOneForUser(id: number, userId: number): Promise<TaskEntity | null> { return this.tasks .createQueryBuilder('task') .leftJoinAndSelect('task.user', 'user') .where('task.id = :id', { id }) .andWhere('user.id = :userId', { userId }) .getOne(); } It is not enough to find a task by its ID and check ownership only in
the frontend. The database query itself must enforce the ownership rule.
Creating a task safely
The create endpoint accepts a title and optional description. It does
not accept an owner ID from the client.
The repository assigns the owner using the authenticated ID:
const task = this.tasks.create({ title: input.title.trim(), description: input.description?.trim() || null, completed: false, user: { id: userId } as TaskEntity['user'], }); This prevents a user from creating a task under another user's account
by changing a userId field in the request.
Updating and deleting safely
The repository first scopes the update lookup by both task ID and user
ID. It only saves the task if that owned task exists.
The delete query also includes both conditions:
.where('id = :id', { id }) .andWhere('user_id = :userId', { userId }) That means a user cannot delete another user's task by guessing its ID.
Why return 404 for another user's task?
When a task does not belong to the requester, the repository returns no
result. The service throws NotFoundException.
This API returns HTTP 404 rather than confirming that another user's
task exists. It avoids exposing information about resources the
requester is not allowed to access.
11. Database schema and migrations
The Task 2 project has its own database configuration and migration. It
uses a separate database named tasks_auth_db, rather than reusing Task
1's tasks_db.
Migration:
src/database/migrations/1700000000001-CreateAuthTasks.ts
The migration creates:
-
userstable with name, unique email, and password hash. -
taskstable with title, description, completion state, anduser_id. - A foreign key from
tasks.user_idtousers.id. - An index on
tasks.user_id. -
ON DELETE CASCADEfor the user-task relationship.
The application keeps synchronize: false and uses explicit migrations.
Run the migration with:
npm run migration:run Check migration status:
npm run migration:show Revert the last migration:
npm run migration:revert 12. Running the project
Create a separate PostgreSQL database named tasks_auth_db.
From the repository root:
cd week-10/Task-02/nest-jwt-auth-api npm install cp .env.example .env Update .env with your PostgreSQL credentials and a long random JWT
secret.
Run the migration and start the API:
npm run migration:run npm run start:dev The API listens on port 3003 by default.
Useful verification commands:
npm run build npm test 13. Testing the authentication and ownership rules
The project includes service-level tests:
-
test/auth.service.spec.ts -
test/tasks.service.spec.ts
The authentication tests check that registration returns a safe user and
token, successful login returns a token, and an unknown user is
rejected.
The task service tests check that the authenticated user's ID is passed
into repository operations, that tasks outside the user's scope are
treated as not found, and that deleting a task outside the user's scope
is rejected.
Manual API test flow
- Register User A.
- Log in as User A and copy the returned
accessToken. - Create a task using User A's token.
- Register and log in as User B.
- Try to list User A's task using User B's token.
- Try to update or delete User A's task using User B's token.
Expected behavior:
- Requests without a valid token receive 401 Unauthorized.
- Each user sees only their own tasks.
- A user cannot read, update, or delete another user's task; the API responds with 404 Not Found.
- A task's owner is taken from the verified JWT, not from client input.
14. Security details worth remembering
Hash passwords, do not encrypt them for storage. Password hashing is
one-way. During login, compare the submitted password with the stored
hash.
Keep secrets out of source code. JWT signing secrets should come
from environment variables and should never be committed.
A JWT is signed, not necessarily encrypted. Do not put sensitive
information in its payload.
Validate request bodies. DTO validation prevents malformed or
unexpected fields from entering the application.
Protect routes on the server. Hiding buttons in a frontend is not
authorization. The API must enforce access rules itself.
Scope database operations by owner. Checking ownership only after
fetching a record is easier to get wrong. Put the ownership condition
into the query used for reading, updating, and deleting.
Do not trust a client-supplied owner ID. The authenticated identity
must come from the verified token.
Use generic login errors. Avoid revealing whether an email address
is registered.
Consider production hardening. This learning project intentionally
omits refresh tokens, password reset, email verification, and rate
limiting. A production system should assess these features, HTTPS,
secret rotation, token revocation strategy, logging, and brute-force
protection.
Conclusion
Week 10 Task 2 took the PostgreSQL-backed Tasks API from Task 1 and
added authentication and ownership-based authorization.
The important learning was understanding the full request lifecycle:
Register/Login → Password verification → JWT issuance → Bearer token →
Passport JWT strategy → Guard → Current user → Owner-scoped database
query
A valid token tells the API who the user is. The owner-scoped repository
queries ensure that the user can access only their own tasks.
That distinction is what makes authentication and authorization work
together in a backend application.

