Every web app with a login has to answer the same questions: how do you prove who the user is on each request, how long does that proof last, and how do you take it back? This guide explains server sessions, JSON Web Tokens (JWTs) and the cookie settings that carry them, and gives a secure login, refresh and logout design you can adapt for web and mobile apps.
For a browser app, keep credentials in cookies marked HttpOnly, Secure and SameSite, never in localStorage. Use either a server-side session or a short-lived access token (5 to 15 minutes) with a refresh token that is stored on the server, rotated on every use and revoked on logout. Verify every JWT with a fixed algorithm, issuer, audience and expiry. Add CSRF protection, a Content Security Policy, rate limiting on login and optional passkeys or 2FA.
1. Sessions or tokens?
Session management keeps a user signed in between requests. There are two main ways to do it:
| Aspect | Server-side session | JWT access token |
|---|---|---|
| What the client holds | An opaque, random session ID | A signed token containing claims |
| Where state lives | Server store (database or Redis) | In the token itself |
| Revoking access | Delete the session: instant | Needs short expiry or a denylist |
| Best fit | Classic web apps on one domain | APIs, mobile apps, several services |
For a server-rendered website, a session cookie is simpler and easier to revoke. JWTs earn their place when several services or a mobile app must verify users without a central store. Many systems combine both.
2. JWT essentials
A JWT is three Base64URL parts joined by dots: header.payload.signature. The payload is encoded, not encrypted: anyone holding the token can read it, so never put passwords, Aadhaar or other personal data in it.
- Claims to include:
iss(issuer),aud(audience),sub(user ID),exp(expiry),iat(issued at) andjti(unique token ID for revocation). - Signing: use an asymmetric algorithm (RS256, ES256 or EdDSA) when more than one service verifies tokens, so verifiers need only the public key. HS256 with a long random secret is fine when one service signs and verifies.
- Key rotation: put a
kid(key ID) in the header and publish public keys as a JWKS, so you can introduce a new key before retiring the old one. - Verification: always pass the expected algorithm, issuer and audience to the library. Never accept
alg: noneor let the token choose the algorithm.
For the attacks behind these rules (algorithm confusion, weak secrets, replay) and revocation patterns, see JWT security best practices. PASETO is a stricter alternative token format worth considering for new projects.
3. Cookie attributes that matter
Set-Cookie: __Host-session=9f2c…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600Set Domain only when subdomains really must share the cookie; leaving it out limits the cookie to the exact host. Safari blocks third-party cookies and Firefox partitions them by default, so design embedded or cross-site flows without relying on them. Partitioned (CHIPS) cookies are the supported option for embedded widgets.
4. CSRF and XSS: the two threats
Cross-site request forgery (CSRF) tricks a signed-in user's browser into sending a request to your site. Defend with SameSite cookies, a CSRF token on every state-changing request, and a check of the Origin header. Don't rely on SameSite alone, especially if you must use SameSite=None.
Cross-site scripting (XSS) runs an attacker's JavaScript on your pages, where it can act as the user even with HttpOnly cookies. Prevent it at the source: escape output by default, set a strict Content Security Policy and keep dependencies updated.
For the CSRF token, a signed double-submit pattern works well with cookie sessions: the server sets a random token, bound to the session and signed with a server secret, in a readable cookie; the front end sends it back in an X-CSRF-Token header; the server checks the signature and that it matches the session.
5. A secure login, refresh and logout flow
- Log in over HTTPS.Rate-limit the endpoint and slow down repeated failures. Verify the password against an Argon2id (or bcrypt) hash, then ask for the second factor if the user has one.
- Issue tokens.Create a short-lived access token (5 to 15 minutes) and a random refresh token. Store only a hash of the refresh token in the database, with the user, device, expiry and status.
- Set cookies.Send the access token in a
SameSite=Laxcookie and the refresh token in aSameSite=Strictcookie scoped toPath=/auth, both HttpOnly and Secure. Rotate any existing session ID at login. - Refresh.When the access token expires, the client calls
POST /auth/refresh. The server checks the refresh token's hash and status, marks it used, and issues a new pair. - Detect reuse.If an already-used refresh token is presented, someone has copied it: revoke that user's whole token family and force a new login.
- Log out.Revoke the refresh token on the server and clear both cookies with
Max-Age=0.
Mobile and other non-browser clients send the access token in an Authorization: Bearer header instead, and keep the refresh token in the iOS Keychain or Android Keystore.
6. Example: refresh rotation in Express
A compact TypeScript sketch of step 4. It uses cookie-parser (without it, req.cookies is undefined) and the Node.js built-in crypto module; db stands for your data layer.
import express from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';
import { randomBytes, createHash } from 'node:crypto';
const app = express();
app.use(cookieParser());
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
const cookieBase = { httpOnly: true, secure: true } as const;
async function issueTokens(res: express.Response, userId: string) {
const access = jwt.sign({ sub: userId }, process.env.JWT_PRIVATE_KEY!, {
algorithm: 'ES256', issuer: 'https://auth.example.com',
audience: 'example-web', expiresIn: '10m',
});
const refresh = randomBytes(32).toString('base64url');
await db.refreshTokens.create({ userId, hash: hash(refresh), expiresAt: Date.now() + 30 * 864e5 });
res.cookie('access_token', access, { ...cookieBase, sameSite: 'lax', path: '/', maxAge: 10 * 60e3 });
res.cookie('refresh_token', refresh, { ...cookieBase, sameSite: 'strict', path: '/auth', maxAge: 30 * 864e5 });
}
app.post('/auth/refresh', async (req, res) => {
const token = req.cookies.refresh_token;
const rec = token && await db.refreshTokens.findByHash(hash(token));
if (!rec || rec.expiresAt < Date.now()) return res.sendStatus(401);
if (rec.used || rec.revoked) { // reuse: assume theft
await db.refreshTokens.revokeAllForUser(rec.userId);
return res.sendStatus(401);
}
await db.refreshTokens.markUsed(rec.id);
await issueTokens(res, rec.userId);
res.json({ ok: true });
});Verify access tokens with jwt.verify(token, publicKey, { algorithms: ['ES256'], issuer, audience }). Express's res.cookie takes maxAge in milliseconds.
7. Cross-origin front ends (CORS)
If the front end and API are on different origins, the API must allow that origin with credentials, and the client must opt in:
import cors from 'cors';
app.use(cors({ origin: ['https://app.example.com'], credentials: true }));
// client: fetch(url, { credentials: 'include' })Never combine credentials: true with a wildcard origin. app.example.com and api.example.com are the same site, so SameSite=Lax cookies still work between them; only a different registrable domain needs SameSite=None; Secure.
8. Security headers and hardening checklist
Strict-Transport-Security: max-age=31536000; includeSubDomainsContent-Security-Policytailored to your app, withframe-ancestors 'none'unless you must be framedX-Content-Type-Options: nosniffandReferrer-Policy: strict-origin-when-cross-origin- Keep signing keys in a secret manager, never in the repository.
- Log sign-ins, failures and password or 2FA changes, and let users see and revoke their active sessions.
- Offer passkeys (WebAuthn) or TOTP 2FA, and use OpenID Connect with Authorization Code + PKCE for "Sign in with…" and mobile apps.
Long-lived access tokens; tokens in localStorage or URLs; refresh tokens without rotation or server-side storage; personal data inside a JWT payload; SameSite=None without Secure; and verifying tokens without pinning the algorithm.
9. Running your app on Domain India
- App Platform: Node.js apps are detected automatically, and other stacks deploy with a Dockerfile. See getting started with the App Platform.
- Shared hosting: cPanel's Node.js tool can run Express apps; see deploying a Node.js app on shared hosting. Free SSL comes with every hosting plan, which the Secure cookie flag needs.
- VPS: self-managed with root access, for your own Redis, database and reverse proxy.
Prices on the cards are live and exclude 18% GST.
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
- 1 vCPU
- 2 GB DDR4 RAM
- 64 GB NVMe SSD Storage
- 2 TB Monthly Bandwidth
Should I store a JWT in localStorage or a cookie?
In a browser, use an HttpOnly, Secure cookie with a suitable SameSite setting. Any script on the page can read localStorage, so a single XSS bug exposes the token. Cookies need CSRF protection, which SameSite plus a CSRF token provides.
How long should access and refresh tokens last?
Access tokens usually last 5 to 15 minutes. Refresh tokens commonly last 7 to 30 days, but should be stored on the server, rotated on every use and revoked on logout or password change.
What is the difference between SameSite Lax and Strict?
Lax cookies are sent when a user follows a normal link to your site but not on cross-site form posts or background requests. Strict cookies are never sent from another site. Lax suits login sessions; Strict suits a refresh-token cookie used only by your own front end.
Is a JWT encrypted?
No. A standard signed JWT is only encoded, so anyone with the token can read its payload. The signature stops tampering but not reading. Keep personal data out of the payload.
How do I log a user out when using JWTs?
Revoke the refresh token in your database and clear the cookies. The access token remains valid until it expires, which is why it should be short-lived; add its jti to a denylist if you need immediate cut-off.
Do subdomains like app.example.com and api.example.com need SameSite=None?
No. Subdomains of the same registrable domain are the same site, so SameSite=Lax cookies work between them. You still need CORS with credentials because they are different origins.
Ready to deploy your app? Start with the App Platform, compare VPS plans, or read JWT security best practices next.
Node.js apps are detected automatically, and other stacks deploy with a Dockerfile.
See App Platform plans