Logout
We can sign up, log in, and protect routes. What we cannot do yet is sign out. If a user closes their laptop and walks away, their session is still live on the server and their cookie is still tucked into their browser. That is a problem, especially on shared or public computers. This lesson adds the missing piece: a POST /logout route that does both halves of the logout dance, and we will also cover a small-but-important question nobody usually asks: why is logout a POST and not a GET?
What logout actually does
Logout is not just one thing. It is two things, and you have to do both or it does not really work:
- Delete the session from the server-side store.
- Tell the browser to delete the cookie.
If you only delete the session but leave the cookie, the cookie sits in the browser pointing to nothing. It is harmless, but it is messy. If you only clear the cookie but leave the session, the cookie is gone but the session is still valid, so anyone who somehow has that session ID (an attacker who copied it, say) can keep using it. Both halves matter.
After both, the session ID in the cookie (if it somehow survives) points to nothing on the server, and the browser will not send the cookie again anyway. Logged out for real.
The logout route
Add this to src/routes/auth.ts:
route.post("/logout", {
resolve: (c) => {
const sessionId = getSessionId(c.request);
if (sessionId) {
deleteSession(sessionId);
}
return Response.json(
{ message: "Logged out" },
{
status: 200,
headers: {
"set-cookie": clearSessionCookie(),
},
},
);
},
}), Add these imports at the top of the file if you do not already have them:
import { getSessionId, clearSessionCookie } from "../cookies.js";
import { deleteSession } from "../sessions.js"; Let’s walk through the handler.
Read the session ID. getSessionId(c.request) extracts the session ID from the cookie header. If there is no cookie (maybe the user never logged in, or already logged out), it returns undefined.
Delete the session. If a session ID exists, we delete it from the store. After this, even if the session ID is reused somehow, getSession will return undefined and authenticate will reject any request that presents it.
Clear the cookie. clearSessionCookie() returns session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0. That Max-Age=0 tells the browser “delete this cookie immediately, it expired zero seconds ago.”
No auth check. This is worth pointing out. We did not call authenticate() in this handler. Logout should succeed even if the session is already invalid or the cookie is missing. Why? Think about the edge case: a user with an expired session tries to log out. If logout required valid auth, they would get a 401, their broken session would not get cleaned up, and they would feel stuck. By making logout work no matter what, we avoid those dead ends. Calling logout once or ten times produces the same result. That property has a name: idempotent.
Why POST, not GET
Here is the question I hinted at in the intro. Logout changes state on the server (we are deleting a record), so it should be a POST, not a GET. Why does this matter so much?
Because GET requests are supposed to be safe. Browsers prefetch links. Search engines crawl them. Browser extensions fire them off in the background. Antivirus scanners follow URLs in emails. If logout were a GET request, any of these could log your user out without them even clicking anything.
But the bigger reason is attacks. Imagine an attacker gets you to visit their malicious page. Embedded on that page is just:
<!-- On a malicious page -->
<img src="https://yourapp.com/logout" /> Your browser sees an <img> tag and tries to fetch it. In doing so, it makes a GET request to https://yourapp.com/logout with your cookies attached (because cookies go automatically to the origin they belong to). If logout were a GET, your user just got logged out by an image on somebody else’s website.
This is a form of cross-site request forgery (CSRF), and for actions more destructive than logout (like “delete account” or “transfer money”), it is catastrophic. The fix is to only do state-changing work on methods that browsers do not make casually: POST, PUT, DELETE. Browsers do not issue those on image loads, link prefetches, or casual navigations.
The rule of thumb: GET requests should never change state on the server. Use POST (or one of the other non-safe methods) for anything that writes, deletes, or modifies. This is not a Hectoday thing. This is an HTTP thing that applies to literally every web app.
Try it out
Sign up, log in, verify access, log out, verify the logout actually stuck:
# Sign up
curl -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
# Log in (save cookies)
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
# Access a protected route (should work)
curl -b cookies.txt http://localhost:3000/me
# Log out
curl -b cookies.txt -c cookies.txt -X POST http://localhost:3000/logout
# Try the protected route again (should fail with 401)
curl -b cookies.txt http://localhost:3000/me After logout, the /me request returns 401 because the session no longer exists on the server and the cookie has been cleared on the client. This is exactly how production apps handle logout. You are doing the real thing.
The full auth flow
Pause and take stock. We now have a complete session-based auth flow:
POST /signup → create account
POST /login → verify credentials, create session, set cookie
GET /me → read cookie, look up session, return user
POST /logout → delete session, clear cookie Every building block works end to end. A user can create an account, log in, hit a protected route, and log out. You just built one of the most common auth flows on the entire web.
The next lesson is the last one of this section, and it goes back to the cookies we have been setting and unpacks every attribute we quietly slipped in. HttpOnly, Secure, SameSite, Path, Max-Age. Each one exists to defend against a specific attack. Understanding them is what separates “the cookie works” from “the cookie is actually secure in production.”
Why does the logout handler not call authenticate()?