Authentication is the part of an application where the difference between working code and correct code is widest. A sign-in flow that authenticates the right people is easy. One that also declines to tell an attacker which email addresses are registered takes a handful of extra decisions, and none of them are visible in the happy path.
The module
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig>) => {
const jwt = config.getOrThrow<AppConfig['jwt']>('jwt')
return {
secret: jwt.secret,
signOptions: {
algorithm: 'HS256',
expiresIn: jwt.expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
verifyOptions: { algorithms: ['HS256'] },
}
},
}),Registering the algorithm explicitly on both halves matters more than it looks. Leaving it
implicit works until the signer and verifier disagree, and an algorithm allowlist on
verifyOptions is what makes a token claiming alg: none fail rather than
being taken at face value.
Because signing and verification are configured in one place, they cannot drift apart — the
guard injects the same JwtService the service does.
Hashing
const BCRYPT_ROUNDS = 12Roughly 250ms on a modern laptop, and deliberately slow. The entire security of a password hash is that an attacker who steals the table cannot try billions of guesses a second against it. Dropping this to speed up a test suite is the wrong trade — tests should hash fewer passwords, not weaker ones.
bcrypt salts each hash itself, so there is no salt to manage and two identical passwords produce
different hashes. Its 72-byte input limit is why RegisterDto caps the password length,
as lesson 6 covered.
Registering, in one transaction
return this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(User, { where: { email } })
if (existing) {
throw new ConflictException('An account with that email already exists.')
}
const user = manager.create(User, {
email,
passwordHash: await bcrypt.hash(dto.password, BCRYPT_ROUNDS),
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
phone: dto.phone?.trim() || null,
role: dto.role,
avatarUrl: null,
deleted: false,
})
await manager.save(user)
// ...
})A contractor also gets a profile row, and it has to be the same transaction. A contractor with a user row and no profile cannot sign in usefully, cannot be found in the directory, and has no id for their quotes to hang off. Two separate saves means a failure between them leaves exactly that account in the database, and nothing will ever come back to fix it.
Registration is also the one place that must admit an address is taken — there is no other way to tell a new user why their sign-up failed. Login stays deliberately vague, and the next section is why that difference is deliberate rather than inconsistent.
The role comes from the DTO, which permits only homeowner or
contractor. There is no code path anywhere in this application that creates a
privileged account from a request.
Logging in, and the two defences
const invalid = new UnauthorizedException('That email and password do not match an account.')
if (!user || user.deleted) {
await bcrypt.compare(dto.password, DUMMY_HASH)
throw invalid
}
const matches = await bcrypt.compare(dto.password, user.passwordHash)
if (!matches) throw invalidThree distinct failures — no account with that address, the account is soft-deleted, the password is wrong — produce one message and one status code.
The shared message is the first defence. Distinguishing them turns the login form into an oracle that confirms which addresses are registered, which is the first step of both credential stuffing and targeted phishing. "No account with that email" is a friendlier message and a worse product.
The dummy comparison is the second, and it is the one people leave out. A hash is compared even when there is no user, and the wasted 250ms is the entire point: returning immediately makes "no such account" measurably faster than "wrong password", and that timing difference is itself the oracle the shared message just closed.
const DUMMY_HASH = '$2b$12$C6UzMDM.H6dfI/f/IKcEe.rNAWK/vNWaVnpUjnaZ5CmNBZ2H8Uu8W'A real bcrypt hash of a value nobody knows. It has to be valid — bcrypt throws on a malformed hash, which would turn the "no such user" path into a 500 anyone can trigger by guessing an unregistered address. Which would be a louder oracle than the one being closed.
The email is lower-cased before the lookup, because Postgres comparison is case-sensitive.
Without normalising, Maya@x.com and maya@x.com are two different accounts
that both pass the unique constraint — and the second can never sign in reliably.
What goes in a token
export interface JwtPayload {
sub: string
email: string
role: UserRole
[HASURA_CLAIMS_NAMESPACE]: HasuraClaims
}The rule that decides everything else: a JWT is signed, not encrypted. Anyone holding it can base64-decode it and read every claim. Signing proves the contents have not been altered; it does not hide them.
So a token carries identifiers and a role, and never a password hash, a personal detail you
would not print, or anything that becomes false quickly. sub here is the public UUID
rather than the internal id, for the same reason the API only ever serialises UUIDs.
The second rule follows from the first: a token is a snapshot. The role in it was true when it was signed and stays in circulation until it expires. Demote someone and their existing token still says otherwise for up to seven days. Anything that must take effect immediately needs a check against the database, or short-lived tokens with refresh, or a revocation list — all of which cost the statelessness that made tokens attractive.
Verifying
const payload = await this.jwtService.verifyAsync<JwtPayload>(token)
const claims = payload[HASURA_CLAIMS_NAMESPACE]
request.user = {
id: claims['x-hasura-user-id'],
publicId: payload.sub,
email: payload.email,
role: payload.role,
contractorId: claims['x-hasura-contractor-id'],
}
return trueverifyAsync, never decode. decode parses the token without
checking the signature, so anyone can hand-write a payload, base64 it, and be whoever they like. It
exists for reading a token you have already verified, and it is the single most dangerous method in
the library — lesson 8 has the full guard.
What the guard attaches is deliberately not the User entity. Loading the
full row on every request would add a query to every endpoint, and most of them only need the ids.
The ones that need the row fetch it themselves — and get a fresh copy rather than one that was true
when the token was signed.
Claims for a second consumer
[HASURA_CLAIMS_NAMESPACE]: {
'x-hasura-allowed-roles': [user.role],
'x-hasura-default-role': user.role,
'x-hasura-user-id': String(user.id),
...(contractorProfileId ? { 'x-hasura-contractor-id': String(contractorProfileId) } : {}),
},This application's tokens are verified by two systems: NestJS, and a GraphQL layer that reads these namespaced claims to pick a permission set. The details are specific to that setup, and two of the decisions generalise to any token with an audience beyond your own code.
One role, not every role. Listing every role the user could have would let the holder pick any of them, because the consumer trusts this list completely — verifying the signature is the only check it performs.
Values are strings. They are substituted into permission rules as text, and a
JSON number produces an error deep inside a rule that never mentions the real cause.
String() on the way in is the whole fix.
The general lesson is that a claim is an instruction to whoever reads it. Put the narrowest truthful thing in it.
Refresh tokens
The seven-day expiry here is a demo convenience, and it is the wrong shape for anything real: long enough that a stolen token is valuable, short enough to be annoying. The standard answer splits it in two.
A short-lived access token — minutes — is what every request carries. A long-lived refresh token is stored server-side, sent only to a refresh endpoint, and exchanged for a new access token. A stolen access token expires quickly; a stolen refresh token can be revoked, because unlike the access token it has a row in a table.
That row is the whole trade. Refresh tokens reintroduce the state that plain JWTs were chosen to avoid — and buy back revocation and the ability to see and end a user's sessions. Rotating them on each use, and treating a reused token as evidence of theft, is the usual refinement.
What is not a solution is a long-lived access token plus a denylist checked on every request: that is a database query per request, which is the cost of sessions with none of their simplicity.
What a token cannot replace
One more limit worth stating, because it explains several decisions above. A JWT proves who the holder was when it was signed. It proves nothing about what they are entitled to now.
So anything whose answer can change between requests should not be read from the token. The role is in there because it changes rarely and the seven-day window is an accepted risk. Whether this contractor won this job is not, and is loaded from the database every time — which is also why the guard attaches ids rather than a full user row.
The general question to ask of every claim: if this became false a minute after signing, what would go wrong? Where the answer is "nothing much", it belongs in the token. Where it is "someone keeps access they should have lost", it does not.
Where the token lives
Worth stating plainly because it is the most common weakness in a Nest tutorial application.
This one returns a token and the client stores it — fine for a local demo, and if that token is in
localStorage, any successful XSS reads it.
The alternative is an httpOnly, Secure, SameSite cookie, which script cannot read and which brings CSRF back as the thing to handle. Both are real designs with real trade-offs; what is not a design is not having thought about it.
Two related settings: a symmetric HS256 secret means anyone holding it can mint a token claiming any identity, which is acceptable when one system signs and verifies and wrong once a second system verifies — that is what RS256 and a public key are for. And CORS must be an explicit allowlist rather than reflecting the request's own origin, because reflecting it with credentials enabled lets any site the user visits call your API as them.
Next: authorization — what to do with the role in that token, and the harder half nobody shows.