Learn JWT Authentication Using Azure Functions v4 and HttpOnly Cookies
AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to add JWT authentication to an Azure Functions v4 API, using an HttpOnly cookie instead of a token in localStorage.
Our blog needs exactly one admin account. One person writes the posts. So there is no user table, no registration, no password reset and no roles. If you need those, most of this still applies — you only replace the one credential check.
Why a Cookie and Not localStorage
Most tutorials store the JWT in localStorage and send it in an Authorization header. It is easy, and it has one real problem.
Any JavaScript on your page can read localStorage. So one XSS bug anywhere on your site — your code, a dependency, an analytics snippet — and your token is gone. The attacker does not need to do anything clever. localStorage.getItem('token') is enough.
An HttpOnly cookie cannot be read by JavaScript at all. The browser attaches it to requests and your code never sees it.
Features of this approach
- The token is invisible to JavaScript.
- The browser sends it automatically, so no header code in your app.
SameSite=Strictblocks it from being sent on cross-site requests.- Nothing to clean up on logout except clearing the cookie.
The cost is that your frontend cannot read the token to know whether it is logged in. We will come back to that.
With the following steps, you can build it.
- Store the admin credentials.
- Sign the token and set the cookie.
- Verify the token on each protected route.
- Handle the session in Angular.
Store the Admin Credentials
Three environment variables, set in api/local.settings.json locally and in the Azure Static Web Apps Application Settings in production.
ADMIN_USERNAMEADMIN_PASSWORD_HASH— a bcrypt hash, never the passwordJWT_SECRET— a long random string
Generate the hash once from the api/ folder.
node -e "require('bcryptjs').hash(process.argv[1], 10).then(console.log)" "your-password"
And the secret.
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
Important: the hash is safe to put in settings, the password is not. And JWT_SECRET is what signs every token — if it leaks, anyone can mint an admin token for your site.
Sign the Token and Set the Cookie
The login handler compares the password against the bcrypt hash, then signs a token.
export function signToken(username: string): string {
return jwt.sign({ username } as TokenPayload, getJwtSecret(), {
expiresIn: JWT_EXPIRY_SECONDS,
});
}
The payload holds only the username. Never put anything secret in a JWT. A JWT is signed, not encrypted — anybody can paste it into jwt.io and read the payload. The signature stops them changing it, not reading it.
Ours expires after 8 hours. Long enough for a writing session, short enough that a stolen token is not permanent.
The cookie is where the security settings live.
export function authCookie(token: string, maxAge: number): Cookie {
return {
name: COOKIE_NAME,
value: token,
httpOnly: true,
secure: !!process.env.WEBSITE_INSTANCE_ID,
sameSite: 'Strict',
path: '/',
maxAge,
};
}
Four flags, four different jobs.
httpOnly: true — JavaScript cannot read it. This is the whole point.
sameSite: 'Strict' — the browser will not send this cookie when the request comes from another site. That removes CSRF for this cookie without a CSRF token.
maxAge — matched to the token expiry, so the browser drops the cookie at the same time the token stops working. Otherwise you keep sending a dead token and get confusing 401s.
secure — conditional, and this one surprises people. WEBSITE_INSTANCE_ID is an environment variable Azure sets and your machine does not. So the cookie is Secure in Azure and not Secure locally.
That is not laziness. Local development runs over plain HTTP through the SWA CLI emulator. A hardcoded secure: true means the browser accepts the login response, silently refuses to store the cookie, and every following request is a 401. The login looks like it worked and nothing works. Deciding it from an Azure-only variable makes it correct in both places with no flag to remember.
Verify the Token on Each Protected Route
Azure Functions v4 has no middleware. There is no app.use(), no route-level guard, no filter pipeline. Each function is registered on its own and called directly.
So the check is a normal function call at the top of every protected handler.
export function requireAuth(
request: HttpRequest
): { valid: true; username: string } | { valid: false; response: HttpResponseInit } {
const result = verifyRequestToken(request);
if (!result.valid || !result.username) {
return {
valid: false,
response: { status: 401, jsonBody: { error: 'Authentication required' } },
};
}
return { valid: true, username: result.username };
}
And in the handler.
const auth = requireAuth(request);
if (!auth.valid) return auth.response;
Two lines, repeated in every protected function. It looks like something you should refactor. Do not. There is nowhere to move it to. If you see this pattern in an Azure Functions codebase, it is the runtime's shape, not sloppiness.
The return type is the part worth copying. It is a discriminated union, so TypeScript knows that after if (!auth.valid) return, auth.username exists. The failure case carries its own ready-made response, so the caller cannot forget what status to send or write a different error message in each file.
The verify itself is small.
export function verifyRequestToken(request: HttpRequest): VerifyResult {
const cookies = parseCookies(request.headers.get('cookie'));
const token = cookies[COOKIE_NAME];
if (!token) return { valid: false };
try {
const payload = jwt.verify(token, getJwtSecret()) as TokenPayload;
return { valid: true, username: payload.username };
} catch {
return { valid: false };
}
}
jwt.verify throws for every failure — bad signature, expired, malformed. The catch turns all of them into the same plain { valid: false }. Do not return the library's error message to the caller. "jwt expired" and "invalid signature" tell an attacker which half of their guess was right.
There is one more use for this function. The blog list endpoint calls verifyRequestToken rather than requireAuth, because it does not require a login — it just shows draft posts as well when the caller happens to be the admin. Same check, two different needs: one that rejects and one that only asks.
Handle the Session in Angular
Here is the trade-off from the beginning. An HttpOnly cookie cannot be read by your app, so the frontend cannot tell whether it is logged in by looking.
The answer is to ask the API. GET /api/auth/session returns whether the cookie that arrived is valid, and AuthService.checkSession() calls it after the app starts.
There is a second reason this must be client-side for us. Our Angular build is fully prerendered with no live SSR server, so nothing on the server knows who is asking at request time. The four admin routes are marked RenderMode.Client rather than prerendered, and authGuard protects them.
Be clear about what the guard does. It hides the screens. It is not security. Anyone can skip your Angular routing entirely and call the API directly, which is exactly why requireAuth sits in every protected handler on the server. The guard is for user experience; the server check is the actual lock.
Conclusion
In this article we learned how to store admin credentials as a bcrypt hash, sign a short-lived JWT, deliver it in an HttpOnly SameSite=Strict cookie, verify it at the top of every protected Azure Function, and check the session from Angular when the token cannot be read.
The pieces are small. The two that matter most are the ones that look wrong at first — the repeated requireAuth call, and the conditional secure flag.
Reference
Comments
Be the first to comment.