Hardening
Get the service ready for strangers: responses serialized so an entity's secrets cannot leak, a rate limit on everything and a tight one on sign-in, helmet's security headers, and CORS for the shelter's own front end and nobody else's.
What you'll learn
- Return entities safely: @Exclude() on a secret column and the ClassSerializerInterceptor for the whole app
- Limit every route with the ThrottlerGuard, and tighten one route with @Throttle()
- Add helmet's security headers and read what each one does
- Allow one front end's origin with CORS, and check both a preflight it passes and one from an origin it refuses
The service works for clients that behave. The public internet is not that: a script tries ten thousand passwords for sam, a page on another site calls the API with a visitor's token, a refactor returns a whole user entity, hash and all. None of this needs a new route. It needs a few defences in front of all of them, each one line of configuration and each one easy to forget. This lesson adds four, and a new GET /auth/me that reads the user from the database instead of trusting the token, which is what makes the first one necessary.
Responses that cannot leak
Lesson 3 kept the hash out by building a new object for every response. That holds until someone returns the entity itself, and /auth/me now does: it reads the user as stored, so a role changed by staff shows at once. The defence belongs on the class. @Exclude() from class-transformer marks a property that must never be serialized, and ClassSerializerInterceptor, registered with APP_INTERCEPTOR, applies the class's rules to everything the app returns.
The interceptor works on instances. An entity loaded by TypeORM is one; { ...user } is a plain object with the same keys and none of the class's rules, so spreading an entity into a response quietly brings the hash back.
Limits
@nestjs/throttler counts requests per client and route. ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]) sets a ceiling, and the ThrottlerGuard as an APP_GUARD applies it to every route. One route deserves less: sign-in, where every request is a password guess. @Throttle() overrides the limit there:
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('reset-password')
Past the limit the answer is 429 Too Many Requests, and it does not matter whether the password was right: the attacker learns nothing from the fourth try, and the owner waits a minute.
Headers
helmet() adds the security headers a browser acts on: X-Content-Type-Options: nosniff stops it guessing a response's type, X-Frame-Options stops another site from framing yours, and a content security policy limits what a page may load. An API serves few pages, but its documentation is one (next lesson), and headers cost nothing.
Who may call from a browser
A page on another origin calling this API with fetch() is refused by the browser unless the API says otherwise, through CORS headers. Before a cross-origin request with a token, the browser sends a preflight, OPTIONS with Origin and the method and headers it wants to use, and sends the real request only if the answer allows them:
app.enableCors({ origin: 'https://adopt.example', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type'] });
Name the origins. origin: true would allow every site on the internet to call the API from its visitors' browsers.
Your task
- Responses are serialized with class-transformer for the whole app, and a user's hash is never in one.
POST /auth/registermay now answer the stored user. GET /auth/meanswers the user as stored in the database, found by the token'ssub.- Every route is limited to 100 requests a minute; sign-in to 3, after which it answers 429.
- helmet's headers on every response.
- CORS for
https://whiskers.exampleonly:GET,POST,PATCHandDELETE, with theContent-TypeandAuthorizationheaders.
Everything from lessons 1 to 3 still holds.
When it fails
passwordHashin a response: the interceptor is not registered, the property has no@Exclude(), or the entity was spread into a plain object on its way out.- The fourth sign-in is a 200:
@Throttle()is missing from login, or theThrottlerGuardis not anAPP_GUARD, so no limit is enforced anywhere. header access-control-allow-origin is missingon the preflight: the origin inenableCorsdiffers from the request's by a character;https://whiskers.example/, with a slash, is another string.header x-content-type-options is missing:helmet()is not applied inmain.ts.
Remember
- Put
@Exclude()on secrets and letClassSerializerInterceptorapply it; return instances, not spreads. - A global ceiling with the
ThrottlerGuard, and@Throttle()where guessing is the risk. helmet()for the headers,enableCors()with named origins for browsers.
Stuck? Show a hint
main.ts: app.use(helmet()) and app.enableCors({ origin: 'https://whiskers.example', methods: [...], allowedHeaders: ['Content-Type', 'Authorization'] }). app.module.ts: ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), APP_GUARD ThrottlerGuard, APP_INTERCEPTOR ClassSerializerInterceptor. User: @Exclude() on passwordHash. Login: @Throttle({ default: { limit: 3, ttl: 60_000 } }). me: read the user by the token's sub with UsersService.findById().