API Access & Documentation

JWT, Login & Session Cookies: A Complete, Modern Guide (2025)

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (9 sections)

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.

Key takeaways

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:

AspectServer-side sessionJWT access token
What the client holdsAn opaque, random session IDA signed token containing claims
Where state livesServer store (database or Redis)In the token itself
Revoking accessDelete the session: instantNeeds short expiry or a denylist
Best fitClassic web apps on one domainAPIs, 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) and jti (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: none or 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.

http
Set-Cookie: __Host-session=9f2c…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
HttpOnly
JavaScript can't read the cookie, so an XSS bug can't simply steal it.
Secure
Sent only over HTTPS. Required for SameSite=None and for the __Host- prefix.
SameSite=Lax
Sent on normal links to your site but not on cross-site form posts or background requests. A good default for login sessions.
SameSite=Strict
Never sent cross-site. Strongest CSRF protection, but users arriving from another site appear logged out on the first page.
__Host- prefix
The browser accepts the cookie only if it is Secure, has Path=/ and no Domain, so a subdomain can't overwrite it.
Max-Age
Lifetime in seconds. Short for access, longer for refresh. Without it, the cookie ends when the browser session ends.

Set 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

  1. 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.
  2. 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.
  3. Set cookies.
    Send the access token in a SameSite=Lax cookie and the refresh token in a SameSite=Strict cookie scoped to Path=/auth, both HttpOnly and Secure. Rotate any existing session ID at login.
  4. 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.
  5. 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.
  6. 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.

typescript
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:

typescript
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; includeSubDomains
  • Content-Security-Policy tailored to your app, with frame-ancestors 'none' unless you must be framed
  • X-Content-Type-Options: nosniff and Referrer-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.
Patterns to avoid

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.

App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
See plan details
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details
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.

Deploy your Node.js app

Node.js apps are detected automatically, and other stacks deploy with a Dockerfile.

See App Platform plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
JWT, Login and Session Cookies: A Modern Guide