Пошук уроків, статей та іншого контенту
Ознайомитеся з cookies, заголовками авторизації та передаванням токенів у запитах до захищених API.
Автентифікація — це перевірка, ким є клієнт: користувачем, застосунком або іншим сервісом.
Авторизація — це перевірка, які дії дозволені вже автентифікованому клієнту.
Наприклад:
Користувач надсилає логін і пароль.
Сервер перевіряє ці дані.
Сервер повертає cookie або токен.
Клієнт додає cookie чи токен до наступних запитів.
Сервер перевіряє їх і вирішує, чи дозволяти доступ.
HTTP сам по собі не зберігає стан між запитами. Кожен запит незалежний, тому клієнт повинен передавати доказ автентифікації в кожному захищеному запиті.
Cookie — це невеликий набір даних, який сервер просить браузер зберегти.
Сервер встановлює cookie за допомогою заголовка Set-Cookie:
HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Lax; Path=/Під час наступного відповідного запиту браузер автоматично додає cookie:
GET /api/profile HTTP/1.1
Host: example.com
Cookie: sessionId=abc123HttpOnly — забороняє доступ до cookie з JavaScript через document.cookie. Це зменшує ризик викрадення cookie через XSS.
Secure — дозволяє надсилати cookie лише через HTTPS. Під час локальної розробки це може вимагати окремого налаштування.
SameSite=Lax — обмежує надсилання cookie у міжсайтових запитах і часто є хорошим базовим значенням.
SameSite=Strict — суворіше обмежує міжсайтове надсилання.
SameSite=None — дозволяє міжсайтове використання, але потребує Secure.
Path=/ — визначає, для яких шляхів cookie доступна.
Max-Age або Expires — визначає час життя cookie.
Для сесійної автентифікації сервер зазвичай зберігає в cookie не всі дані користувача, а випадковий ідентифікатор сесії. Саму сесію сервер зберігає у базі даних або іншому сховищі.
fetchЗа замовчуванням fetch поводиться по-різному залежно від походження запиту.
Для запитів до того самого походження cookie зазвичай надсилаються автоматично. Для міжсайтових або кросдоменних запитів потрібно явно вказати credentials.
const response = await fetch("https://api.example.com/profile", {
method: "GET",
credentials: "include",
});
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
const profile = await response.json();
console.log(profile);Можливі значення credentials:
"omit" — не надсилати cookies;
"same-origin" — надсилати cookies лише для того самого походження;
"include" — надсилати cookies також у кросдоменних запитах.
Для кросдоменного запиту сервер також повинен правильно налаштувати CORS. Зокрема, він не може використовувати Access-Control-Allow-Origin: * разом із credentials. Потрібно вказати конкретне походження клієнта:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: trueAuthorizationІнший поширений спосіб — передавати облікові дані в заголовку Authorization.
Найчастіший формат для токенів:
Authorization: Bearer eyJhbGciOi...У JavaScript:
const accessToken = "token-from-login-response";
const response = await fetch("https://api.example.com/orders", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (response.status === 401) {
console.error("Токен відсутній або недійсний");
} else if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
} else {
const orders = await response.json();
console.log(orders);
}Bearer означає, що той, хто володіє токеном, може використовувати його для доступу. Тому токен потрібно захищати так само уважно, як пароль.
AuthorizationHTTP визначає різні схеми авторизації. Наприклад:
Authorization: Basic dXNlcjpwYXNzd29yZA==Basic кодує пару логін-пароль у Base64, але не шифрує її. Його можна використовувати лише через HTTPS, а для сучасних вебзастосунків частіше обирають сесії або токени.
Токен доступу — це значення, яке сервер видає після успішної автентифікації.
Типовий сценарій:
async function login(email, password) {
const response = await fetch("https://api.example.com/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error("Не вдалося виконати вхід");
}
const data = await response.json();
// Наприклад, сервер повертає accessToken у тілі відповіді.
return data.accessToken;
}
async function loadProfile(accessToken) {
const response = await fetch("https://api.example.com/profile", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status === 401) {
throw new Error("Сеанс завершено або токен недійсний");
}
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
return response.json();
}Токен може бути:
непрозорим — випадковим рядком, значення якого можна перевірити лише на сервері;
JWT — структурованим токеном із частинами, які містять дані та підпис.
Наявність даних усередині JWT не означає, що вони зашифровані. Вміст JWT зазвичай можна декодувати, тому в ньому не можна зберігати паролі, секрети або іншу конфіденційну інформацію.
Сервер повинен перевіряти:
підпис токена;
термін його дії;
видавця та аудиторію, якщо вони використовуються;
права доступу;
відкликання токена, якщо така можливість передбачена.
Обидва підходи можуть бути правильними.
Переваги:
браузер автоматично надсилає cookie;
HttpOnly не дозволяє прочитати cookie з JavaScript;
зручно для класичних вебзастосунків.
Ризик:
оскільки браузер надсилає cookie автоматично, потрібно захищатися від CSRF.
AuthorizationПереваги:
токен явно додається клієнтом;
зручно для мобільних застосунків і API;
браузер не додає такий заголовок автоматично до довільного сайту.
Ризики:
токен потрібно десь зберігати;
токен у localStorage доступний JavaScript-коду, тому XSS може призвести до його викрадення;
потрібно продумати термін дії та оновлення токенів.
Не існує універсального правила, за яким один підхід завжди кращий. Вибір залежить від типу клієнта, архітектури API та моделі загроз.
Нижче наведено самодостатній приклад для Node.js 18 або новішої версії. Сервер:
приймає логін;
створює сесію;
повертає Set-Cookie;
захищає маршрут /profile;
приймає автентифікацію або через cookie, або через Authorization: Bearer ....
Запустіть файл командою node auth-example.js.
const http = require("node:http");
const crypto = require("node:crypto");
const sessions = new Map();
function sendJson(response, statusCode, data, headers = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
...headers,
});
response.end(JSON.stringify(data));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
// Обмежуємо розмір тіла запиту для прикладу.
if (body.length > 1_000_000) {
reject(new Error("Тіло запиту завелике"));
request.destroy();
}
});
request.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Некоректний JSON"));
}
});
request.on("error", reject);
});
}
function parseCookies(cookieHeader = "") {
return Object.fromEntries(
cookieHeader
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) {
return [part, ""];
}
const name = part.slice(0, separatorIndex);
const value = part.slice(separatorIndex + 1);
return [name, decodeURIComponent(value)];
})
);
}
function getTokenFromRequest(request) {
const authorization = request.headers.authorization;
if (authorization?.startsWith("Bearer ")) {
return authorization.slice("Bearer ".length);
}
const cookies = parseCookies(request.headers.cookie);
return cookies.sessionId;
}
function getAuthenticatedUser(request) {
const token = getTokenFromRequest(request);
if (!token) {
return null;
}
return sessions.get(token) ?? null;
}
const server = http.createServer(async (request, response) => {
try {
if (request.method === "POST" && request.url === "/login") {
const body = await readJson(request);
if (body.email !== "user@example.com" || body.password !== "secret") {
return sendJson(response, 401, {
error: "Неправильний логін або пароль",
});
}
const sessionId = crypto.randomBytes(32).toString("hex");
sessions.set(sessionId, {
id: 1,
email: body.email,
roles: ["user"],
});
return sendJson(
response,
200,
{ message: "Вхід успішний" },
{
// У production слід використовувати Secure разом із HTTPS.
"Set-Cookie": [
`sessionId=${sessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`,
],
}
);
}
if (request.method === "GET" && request.url === "/profile") {
const user = getAuthenticatedUser(request);
if (!user) {
return sendJson(response, 401, {
error: "Потрібна автентифікація",
});
}
return sendJson(response, 200, {
id: user.id,
email: user.email,
roles: user.roles,
});
}
sendJson(response, 404, { error: "Маршрут не знайдено" });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(3000, async () => {
console.log("Сервер: http://localhost:3000");
const loginResponse = await fetch("http://localhost:3000/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "user@example.com",
password: "secret",
}),
});
const setCookie = loginResponse.headers.get("set-cookie");
const sessionId = setCookie?.match(/sessionId=([^;]+)/)?.[1];
if (!sessionId) {
console.error("Сервер не повернув sessionId");
return;
}
const cookieProfileResponse = await fetch("http://localhost:3000/profile", {
headers: {
Cookie: `sessionId=${sessionId}`,
},
});
console.log("Відповідь із cookie:", await cookieProfileResponse.json());
const tokenProfileResponse = await fetch("http://localhost:3000/profile", {
headers: {
Authorization: `Bearer ${sessionId}`,
},
});
console.log(
"Відповідь із Authorization:",
await tokenProfileResponse.json()
);
});У реальному застосунку:
пароль не порівнюють із рядком у коді;
паролі зберігають лише у вигляді результату спеціального хешування;
сесії зберігають у надійному зовнішньому сховищі;
використовується HTTPS;
токени мають обмежений термін дії;
помилки не розкривають зайвих деталей.
Захищені API повинні коректно розрізняти помилки:
401 Unauthorized — клієнт не автентифікований або передав недійсні облікові дані;
403 Forbidden — клієнт автентифікований, але не має потрібних прав;
400 Bad Request — запит має некоректний формат;
404 Not Found — ресурс або маршрут не знайдено;
429 Too Many Requests — перевищено ліміт запитів.
Наприклад, 401 може означати завершення терміну дії access-токена. Клієнт у такому випадку може спробувати оновити токен або попросити користувача виконати вхід повторно.
У багатьох системах використовують два токени:
access-токен — короткоживучий, використовується для API-запитів;
refresh-токен — довгоживучий, використовується для отримання нового access-токена.
Типовий процес:
Користувач виконує вхід.
Сервер видає access-токен і refresh-токен.
Клієнт надсилає access-токен до API.
Після завершення терміну дії access-токена клієнт звертається до endpoint оновлення.
Сервер перевіряє refresh-токен і видає новий access-токен.
Refresh-токен потрібно захищати особливо ретельно. Один із поширених варіантів для браузера — зберігати його в HttpOnly cookie, а короткоживучий access-токен використовувати лише в пам’яті застосунку.
CSRF виникає, коли шкідливий сайт змушує браузер користувача надіслати запит до іншого сайту. Якщо автентифікація базується на cookie, браузер може автоматично додати цю cookie.
Захист від CSRF може включати:
SameSite для cookie;
CSRF-токени;
перевірку заголовків Origin або Referer;
відмову від небезпечних операцій без додаткового захисного значення.
XSS дозволяє зловмисному JavaScript-коду виконуватися у контексті вашого сайту.
Щоб зменшити ризик:
екрануйте дані, які вставляються в HTML;
не використовуйте innerHTML для неперевірених даних;
застосовуйте Content Security Policy;
не зберігайте довгоживучі секрети у localStorage, якщо безпечніший варіант доступний;
використовуйте HttpOnly для cookie, які не повинні читатися JavaScript-кодом.
HttpOnly не захищає від самого XSS. Він лише ускладнює безпосереднє викрадення cookie. Шкідливий скрипт усе одно може виконувати запити від імені користувача, якщо сторінка вже завантажена у його контексті.
Щоб не дублювати код перевірки відповідей і додавання токена, можна створити окрему функцію:
let accessToken = null;
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
accessToken = null;
throw new Error("Потрібно виконати вхід повторно");
}
if (!response.ok) {
throw new Error(`Помилка API: ${response.status}`);
}
return response;
}
async function createOrder(order) {
const response = await apiFetch("/api/orders", {
method: "POST",
body: JSON.stringify(order),
});
return response.json();
}Важливо не додавати Authorization до запитів без потреби, особливо до сторонніх доменів. Заголовки можуть бути записані в журнали, потрапити до інструментів моніторингу або бути випадково передані не тому серверу.
Небезпечний варіант:
https://api.example.com/profile?token=secret-tokenURL може потрапити до історії браузера, журналів вебсервера, проксі або заголовка Referer.
Використовуйте заголовок Authorization або захищену cookie.
Логін, пароль, cookie та токени не можна передавати через звичайний HTTP у production. HTTPS захищає дані під час передавання між клієнтом і сервером.
localStorage без оцінки ризиківlocalStorage доступний будь-якому JavaScript-коду на сторінці. Якщо в застосунку є XSS, токен може бути викрадений.
credentialsКлієнт:
fetch("https://api.example.com/profile", {
credentials: "include",
});але сервер не надсилає правильні CORS-заголовки. У такому разі браузер заблокує доступ до відповіді або не дозволить виконати запит у потрібному режимі.
Сервер не повинен зберігати паролі як звичайний текст. Навіть адміністратори бази даних не повинні мати можливості прочитати пароль користувача.
401 і 403401 означає відсутність дійсної автентифікації. 403 означає, що автентифікація є, але доступ заборонений.
Клієнт може прочитати дані JWT, але це не означає, що їм можна довіряти. Остаточну перевірку підпису, терміну дії та прав завжди виконує сервер.
response.okfetch не відхиляє Promise для статусів 401, 403 або 500. Promise відхиляється переважно через мережеву помилку. Тому HTTP-статус потрібно перевіряти самостійно.
HTTP-запити не зберігають стан, тому облікові дані потрібно передавати в кожному захищеному запиті.
Cookies надсилаються браузером автоматично та підходять для сесійної автентифікації.
Для сесійних cookies варто використовувати HttpOnly, Secure і відповідне значення SameSite.
Токени часто передають у заголовку Authorization: Bearer ....
Токени не слід передавати в URL.
401 означає проблему з автентифікацією, а 403 — відсутність потрібних прав.
Для cookies потрібно враховувати CSRF, а для токенів, доступних JavaScript, — ризик XSS.
У production необхідні HTTPS, обмежений час життя токенів, безпечне зберігання сесій і коректне налаштування CORS.