Wenn ein JWT in localStorage liegt, kann jedes injizierte Script es lesen und wegschicken. Das ist der unangenehme Teil vieler SPA-Auth-Setups: Der Token ist bequem fuer JavaScript, aber genau diese Bequemlichkeit macht ihn nach einer XSS-Luecke leicht stehlbar.
Der bessere Default in 2026 ist, sensitive Tokens aus JavaScript herauszuhalten und den Transport dem Browser ueber HTTP-only Cookies zu ueberlassen.
Wenn derselbe Token in einem HTTP-only Cookie liegt, passieren drei nuetzliche Dinge:
- JavaScript kann ihn nicht lesen, daher verliert injizierter Code den einfachsten Weg zum Token-Diebstahl.
- Der Browser sendet ihn automatisch mit passenden Requests.
- Cookie-Flags wie
Secure,HttpOnlyundSameSiteschuetzen zusaetzlich gegen Sniffing und einige CSRF-Muster.
Das entfernt nicht jedes Authentifizierungsrisiko. Fuer unsafe Methods brauchst du weiterhin CSRF-Schutz, kurze Access-Token-Laufzeiten, Refresh-Rotation und sauberes Logout-Verhalten. Aber es ist eine staerkere Basis als Bearer Tokens im Browser Storage.
Server Side: Tokens mit Node.js ausstellen
Hier ist ein minimales Express-Setup.
npm install express cookie-parser jsonwebtoken
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(express.json());
app.use(cookieParser());
Fuer Produktion definiere ich Cookie-Optionen meistens zentral:
const isProduction = process.env.NODE_ENV === 'production';
const authCookie = {
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
path: '/',
};
httpOnly haelt den Token von JavaScript fern. secure sorgt dafuer, dass das Cookie in Produktion nur ueber HTTPS gesendet wird. sameSite: 'strict' ist ein konservativer Default fuer Same-Site-Apps.
Wenn Frontend und API auf unterschiedlichen Sites liegen, brauchst du eventuell sameSite: 'none' plus secure: true, und CORS muss sauber konfiguriert werden.
Login Endpoint
Nach erfolgreicher Credential-Pruefung erzeugst du einen Access Token und einen Refresh Token. Der Access Token sollte kurzlebig sein. Der Refresh Token darf laenger leben, sollte aber trotzdem rotiert und widerrufbar sein.
import jwt from 'jsonwebtoken';
app.post('/api/token', async (req, res) => {
const { username, password } = req.body;
// 1. Credentials pruefen.
// 2. User laden.
// 3. Inaktive oder gesperrte Accounts ablehnen.
const accessToken = jwt.sign(
{ sub: username },
process.env.ACCESS_SECRET,
{ expiresIn: '5m' },
);
const refreshToken = jwt.sign(
{ sub: username },
process.env.REFRESH_SECRET,
{ expiresIn: '1d' },
);
res
.cookie('access_token', accessToken, {
...authCookie,
maxAge: 5 * 60 * 1000,
})
.cookie('refresh_token', refreshToken, {
...authCookie,
maxAge: 24 * 60 * 60 * 1000,
})
.json({ user: username, status: 'active' });
});
Der Client bekommt nur harmlose User-Informationen im JSON. Die sensitiven Tokens bleiben in Cookies, die Frontend-JavaScript nicht lesen kann.
Es ist weiterhin okay, oeffentlichen UI-State wie username, Theme oder Feature Flags in localStorage zu speichern. Die wichtige Regel lautet: keine Bearer Tokens dort speichern.
Refresh Endpoint
Der Refresh Endpoint liest den Refresh Token aus den Cookies. Wenn der Token gueltig ist, stellt er ein neues Paar aus.
app.post('/api/token/refresh', (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.sendStatus(401);
}
jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
if (err) {
return res.sendStatus(403);
}
const newAccess = jwt.sign(
{ sub: decoded.sub },
process.env.ACCESS_SECRET,
{ expiresIn: '5m' },
);
const newRefresh = jwt.sign(
{ sub: decoded.sub },
process.env.REFRESH_SECRET,
{ expiresIn: '1d' },
);
res
.cookie('access_token', newAccess, {
...authCookie,
maxAge: 5 * 60 * 1000,
})
.cookie('refresh_token', newRefresh, {
...authCookie,
maxAge: 24 * 60 * 60 * 1000,
})
.json({ ok: true });
});
});
In einem echten Produktionssystem solltest du auch eine Refresh-Token-ID serverseitig speichern, damit alte Refresh Tokens widerrufen, Reuse erkannt und Sessions nach Logout invalidiert werden koennen.
CSRF-Schutz bleibt notwendig
HTTP-only Cookies reduzieren XSS-basierten Token-Diebstahl, entfernen aber kein CSRF-Risiko. Der Browser sendet Cookies automatisch, auch bei manchen unerwuenschten Cross-Site-Requests.
Fuer unsafe Methods wie POST, PUT, PATCH und DELETE brauchst du einen CSRF Token.
Ein gaengiges Muster:
- Der Server setzt ein nicht-HTTP-only CSRF Cookie oder liefert einen CSRF Token ueber einen Endpoint.
- Der Client liest diesen CSRF-Wert.
- Der Client sendet ihn in einem
X-CSRF-TokenHeader zurueck. - Der Server prueft, ob der Header zum erwarteten Wert passt.
app.get('/api/csrf-token', (req, res) => {
const token = crypto.randomUUID();
res
.cookie('csrf_token', token, {
secure: isProduction,
sameSite: 'strict',
path: '/',
})
.set('X-CSRF-Token', token)
.json({ ok: true });
});
Du kannst das manuell umsetzen, Framework-Unterstuetzung nutzen oder ein gepflegtes CSRF-Package verwenden. Die Kernidee bleibt gleich: Unsafe Requests muessen beweisen, dass sie von deinem Frontend-Code kommen und nicht von einem zufaelligen Form Post einer anderen Site.
React Client: Login mit Cookies und CSRF
Der Client behandelt JWTs nicht mehr manuell. Er bittet den Browser, Cookies mitzuschicken, indem credentials: 'include' gesetzt wird.
async function getCSRF() {
const response = await fetch('/api/csrf-token', {
credentials: 'include',
});
return response.headers.get('x-csrf-token');
}
Diesen Token nutzt du dann beim Login:
const loginUser = async (event) => {
event.preventDefault();
const csrf = await getCSRF();
const response = await fetch('/api/token', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify({
username: event.target.username.value,
password: event.target.password.value,
}),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('username', data.user);
return;
}
alert('Wrong login or password');
};
Die Trennung ist klar:
- Cookies halten sensitive Tokens,
localStoragehaelt nur harmlose Anzeigeinformationen,- der CSRF Token wird fuer unsafe Requests als Header gesendet.
Access Token automatisch erneuern
Wenn Access Tokens nach fuenf Minuten ablaufen, erneuere sie kurz vor Ablauf.
useEffect(() => {
const timer = setInterval(async () => {
const csrf = await getCSRF();
await fetch('/api/token/refresh', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
});
}, 4.5 * 60 * 1000);
return () => clearInterval(timer);
}, []);
Nutzer koennen weiterarbeiten, ohne staendig "session expired" Popups zu sehen, waehrend der Access Token kurzlebig bleibt.
Du kannst auch reaktiv refreshen: Wenn ein API Call 401 zurueckgibt, rufe einmal /api/token/refresh auf und wiederhole danach den urspruenglichen Request.
Fuenf praktische Szenarien
Cookie-basierte Auth veraendert einige typische Workflows.
1. Grosse Dateien hochladen
Weil Cookies automatisch mitgesendet werden, bleibt Upload-Code sauber.
const uploadFile = async (file) => {
const data = new FormData();
data.append('file', file);
await fetch('/api/upload', {
method: 'POST',
body: data,
credentials: 'include',
});
};
Trotzdem brauchst du CSRF-Schutz fuer Uploads, wenn sie Serverzustand veraendern.
2. Server-Side Rendering mit Next.js
Waehrend SSR ist der Access Token in den Request Cookies verfuegbar.
export async function getServerSideProps({ req }) {
const token = req.cookies.access_token;
// Daten im Namen des Nutzers laden.
return {
props: {},
};
}
So kannst du private Daten vorladen und fertiges HTML zurueckgeben, ohne den Token fuer Browser-JavaScript sichtbar zu machen.
3. Realtime Chat ueber WebSocket
Bei Same-Site-WebSocket-Verbindungen senden Browser normalerweise Cookies waehrend des WebSocket-Handshakes mit.
Auf dem Server solltest du das Cookie validieren und den Origin Header pruefen, bevor die Verbindung akzeptiert wird.
Wenn deine Infrastruktur Cookies entfernt oder du Cross-Site-WebSocket-Auth brauchst, packe keine langlebigen JWTs in die URL. URLs koennen in Logs landen. Nutze lieber ein kurzlebiges, einmaliges Connection Ticket.
4. API Gateway vor Microservices
Ein API Gateway kann das Cookie lesen, den Token pruefen und vertrauenswuerdige Identitaetsinformationen an interne Services weitergeben.
Eine andere Option ist, den Token nach der Validierung in einen internen Authorization Header zu kopieren.
Die inneren Services bleiben unabhaengig von Browser-Details. Sie vertrauen entweder dem Gateway oder pruefen einen service-seitigen Token.
5. Mobile Builds mit React Native
React Native ist anders. Mobile Apps haben keine browser-erzwungenen HTTP-only Cookies im gleichen Sinn.
Nutze stattdessen den sicheren Speicher der Plattform:
- Keychain auf iOS,
- Keystore auf Android,
- einen gepflegten Secure-Storage-Wrapper.
Das ist das naechste mobile Aequivalent: Refresh Credentials verschluesselt und von anderen Apps isoliert speichern.
Takeaways
HTTP-only Cookies sind kein magischer Schild, aber sie sind ein besserer Default fuer SPA-Authentifizierung als Bearer Tokens in localStorage.
Die praktische Checkliste:
- JWTs nicht in
localStoragespeichern, - HTTP-only Cookies fuer Access und Refresh Tokens nutzen,
Secure,HttpOnlyundSameSitebewusst setzen,- unsafe Methods mit CSRF Tokens schuetzen,
- Access Tokens kurzlebig halten,
- Refresh Tokens rotieren,
- Sessions serverseitig widerrufen, wenn Nutzer sich ausloggen,
- Origins fuer WebSocket-Flows validieren.
Der Wechsel ist groesstenteils Wiring. Der Gewinn ist deutlich: weniger Wege fuer injiziertes JavaScript, eine Nutzeridentitaet zu stehlen, saubererer Client-Code und ein Security-Modell, das besser dazu passt, wie Browser wirklich funktionieren.


