
Тема хранения токенов — одна из самых больных в авторизации. Многие, не до конца понимая, как авторизация должна работать, кладут оба токена в cookie или — что ещё хуже — в localStorage. В этой статье мы соберём схему, в которой access‑token живёт в памяти приложения, refresh‑token — в HttpOnly‑cookie, а фронтенд сам восстанавливает сессию после перезагрузки страницы и незаметно обновляет протухший access.
Бекенд — на Go, фронтенд — на Vue 3 (Vuex + vue‑router + axios), но концепция так же применима к React и Next.js. Верстку и стили оставляю за кадром — в статье только логика авторизации.
Зачем нужны два токена
Проще всего понять на аналогии с фестивалем:
access_token — это браслет. Его проверяют на каждом входе, то есть он прикладывается к каждому запросу к API. Живёт он недолго: обычно 5–15 минут.
refresh_token — это паспорт. Его показывают только в кассе — на эндпоинте
/refresh— чтобы получить новый браслет. Живёт он дни и даже месяцы.
Логика такая: даже если браслет срисуют (access утечёт), он скоро перестанет работать. А «паспорт» мы прячем в HttpOnly‑cookie, которую не прочитать из JavaScript.
Что у токена внутри: кодировка и подпись
JWT‑токен — это строка из трёх частей, разделённых точками: header.payload.signature.
header — JSON с алгоритмом подписи;
payload — JSON с данными (claims): например, телефон и срок жизни;
signature — “печать”.
Первые две части закодированы в Base64Url — это обычный Base64, только с символами - и _ вместо + и / и без = на конце, чтобы токен можно было безопасно передавать в URL и заголовках. Важно: Base64Url — это кодировка, а не шифрование. Payload может декодировать и прочитать кто угодно. Это как открытка: до адресата дойдёт, но текст по дороге читает кто хочет. Поэтому в токен нельзя класть пароли и любые секреты.
Третья часть — подпись. В нашем случае это HMAC SHA‑256: берём строку header.payload, прогоняем через HMAC с секретным ключом и получаем строку, которую невозможно подделать, не зная ключа. Если хоть один символ payload изменить — «печать» не сойдётся.
Соберём токен руками, чтобы увидеть каждый шаг:
// импорты: crypto/hmac, crypto/sha256, encoding/base64, encoding/json, time func b64Encode(data []byte) string { return base64.RawURLEncoding.EncodeToString(data) } // Ручная генерация токена (HS256 = HMAC + SHA-256) func manualSign(payload map[string]any, key []byte) (string, error) { header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) body, err := json.Marshal(payload) if err != nil { return "", err } signingInput := b64Encode(header) + "." + b64Encode(body) // это мы подписываем mac := hmac.New(sha256.New, key) // тот самый HMAC SHA-256 mac.Write([]byte(signingInput)) return signingInput + "." + b64Encode(mac.Sum(nil)), nil }
token, _ := manualSign(map[string]any{ "phone": "+79999999998", "type": "access", "exp": time.Now().Add(15 * time.Minute).Unix(), }, jwtKey)
Проверка — в обратном порядке:
func manualVerify(token string, key []byte) (map[string]any, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, errors.New("malformed token") } // 1. Пересчитываем «печать» и сравниваем в constant-time mac := hmac.New(sha256.New, key) mac.Write([]byte(parts[0] + "." + parts[1])) if !hmac.Equal([]byte(b64Encode(mac.Sum(nil))), []byte(parts[2])) { return nil, errors.New("invalid signature") } // 2. Печать сошлась — декодируем payload body, _ := base64.RawURLEncoding.DecodeString(parts[1]) var payload map[string]any if err := json.Unmarshal(body, &payload); err != nil { return nil, err } // 3. Подпись валидна, но токен мог протухнуть — проверяем срок if exp, ok := payload["exp"].(float64); ok && time.Now().Unix() > int64(exp) { return nil, errors.New("token expired") } return payload, nil }
Обратите внимание на шаг 3: валидная подпись не означает валидный токен — срок жизни проверяется отдельно. В ручной реализации такие вещи легко забыть, поэтому в бою проще использовать проверенную библиотеку — ниже мы так и сделаем.
Две атаки, о которых стоит знать
alg: none. Атакующий пишет в header “токен не подписан” и надеется, что сервер поверит заголовку. Защита: сервер никогда не верит заголовку и держит фиксированный список разрешённых алгоритмов.Подмена алгоритма. Сервер ждёт один алгоритм, а атакующий шлёт токен, подписанный другим, в расчёте на путаницу. Защита та же: фиксированный список.
В golang-jwt это одна строка: jwt.WithValidMethods([]string{"HS256"}).
Почему access и refresh подписываются разными ключами
Представьте, что мы подписали оба токена одним ключом с одинаковыми данными. Тогда долгоживущий refresh будет проходить все проверки как access — атакующий получит фактически вечный «браслет». Поэтому:
access и refresh подписываются разными ключами;
для надёжности в сам токен кладём поле type (
"access"или"refresh") и проверяем его на сервере.
В некоторых реализациях токенам ещё дают разные префиксы в строке. Смысл один: access и refresh — разные сущности и не должны быть взаимозаменяемы.
Бекенд на Go

Небольшое, но важное замечание: популярный когда‑то github.com/dgrijalva/jwt-go заброшен и содержит известные уязвимости. Используем поддерживаемый форк github.com/golang-jwt/jwt/v5.
Обвязка:
package main import ( "database/sql" "encoding/json" "log" "net/http" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" _ "github.com/lib/pq" "github.com/rs/cors" "golang.org/x/crypto/bcrypt" ) var ( db *sql.DB jwtKey = []byte(os.Getenv("JWT_SECRET")) // в проде — только из env refreshTokenKey = []byte(os.Getenv("JWT_REFRESH_SECRET")) // разные ключи для access и refresh secureCookies = os.Getenv("SECURE_COOKIES") == "true" // для локального HTTP — false ) type User struct { ID int `json:"id"` Username string `json:"username"` // уникальное Phone string `json:"phone"` // уникальное Name string `json:"name"` Lastname string `json:"lastname"` Password string `json:"password"` Icons string `json:"icons"` } // Ошибка в формате JSON func writeError(w http.ResponseWriter, message string, status int) { w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"result": message}) }
Генерация токенов — отличается сроком жизни, ключом и типом:
type Claims struct { Phone string `json:"phone"` Type string `json:"type"` // "access" или "refresh" jwt.RegisteredClaims } func generateAccessToken(phone string) (string, error) { claims := &Claims{ Phone: phone, Type: "access", RegisteredClaims: jwt.RegisteredClaims{ // 15 минут в проде; для демо поставьте 1 минуту, // чтобы увидеть работу перехватчика на фронтенде ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtKey) } func generateRefreshToken(phone string) (string, error) { claims := &Claims{ Phone: phone, Type: "refresh", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(refreshTokenKey) }
Регистрация
Глубоко закапываться не будем: проверяем уникальность username и phone, хэшируем пароль bcrypt и сохраняем:
func registerHandler(w http.ResponseWriter, r *http.Request) { var user User if err := json.NewDecoder(r.Body).Decode(&user); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } var existing string if db.QueryRow("SELECT username FROM users WHERE username=$1", user.Username).Scan(&existing) == nil { writeError(w, "Никнейм занят", http.StatusConflict) return } if db.QueryRow("SELECT phone FROM users WHERE phone=$1", user.Phone).Scan(&existing) == nil { writeError(w, "Телефон уже зарегистрирован", http.StatusConflict) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } _, err = db.Exec( "INSERT INTO users (username, phone, name, lastname, password, icons) VALUES ($1,$2,$3,$4,$5,$6)", user.Username, user.Phone, user.Name, user.Lastname, hashedPassword, user.Icons, ) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Регистрация прошла успешно!"}) }
Логин
Проверяем телефон и пароль, генерируем пару токенов. Refresh ставим в защищённую cookie, access отдаём в теле ответа:
func loginHandler(w http.ResponseWriter, r *http.Request) { var user User if err := json.NewDecoder(r.Body).Decode(&user); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } var storedPassword string err := db.QueryRow("SELECT password FROM users WHERE phone=$1", user.Phone).Scan(&storedPassword) if err != nil || bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(user.Password)) != nil { writeError(w, "Логин или пароль не совпадают", http.StatusUnauthorized) return } accessToken, err := generateAccessToken(user.Phone) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken(user.Phone) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } // Refresh — только в защищённую cookie http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: refreshToken, Path: "/", HttpOnly: true, // JS не прочитает cookie — защита от кражи Secure: secureCookies, // true в проде: cookie ходит только по HTTPS SameSite: http.SameSiteStrictMode, // cookie не уйдёт с чужого сайта — защита от CSRF }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"access_token": accessToken}) }
Три атрибута cookie простыми словами:
HttpOnly— JavaScript не может прочитать cookie, её можно только автоматически пересылать с запросами. Именно это нам и нужно.Secure— cookie отправляется только по HTTPS. Локально можно держать выключенным, при деплое — обязательно включить.SameSite=Strict— браузер не приложит cookie, если запрос идёт с чужого сайта.
Обновление токенов
Access живёт минуты. Без эндпоинта обновления пользователь очень быстро начнёт получать 401. /refresh читает cookie, валидирует refresh и выдаёт новую пару (ротация):
func refreshHandler(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("refresh_token") if err != nil { writeError(w, "Refresh token отсутствует", http.StatusUnauthorized) return } claims := &Claims{} tkn, err := jwt.ParseWithClaims(cookie.Value, claims, func(t *jwt.Token) (interface{}, error) { return refreshTokenKey, nil }, jwt.WithValidMethods([]string{"HS256"}), ) if err != nil || !tkn.Valid || claims.Type != "refresh" { writeError(w, "Неверный refresh token", http.StatusUnauthorized) return } newAccess, err := generateAccessToken(claims.Phone) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } newRefresh, err := generateRefreshToken(claims.Phone) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: newRefresh, Path: "/", HttpOnly: true, Secure: secureCookies, SameSite: http.SameSiteStrictMode, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"access_token": newAccess}) }
Логаут
Фронтенд не может удалить HttpOnly‑cookie сам — он может её только отправлять. Поэтому удаляет cookie бекенд:
func logoutHandler(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, SameSite: http.SameSiteStrictMode, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Вы успешно разлогинились!"}) }
Middleware для защищённых маршрутов и профиль
Токен принимаем по стандарту — с префиксом Bearer:
func tokenFromRequest(r *http.Request) string { return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } func validateToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims := &Claims{} tkn, err := jwt.ParseWithClaims(tokenFromRequest(r), claims, func(t *jwt.Token) (interface{}, error) { return jwtKey, nil }, jwt.WithValidMethods([]string{"HS256"}), ) if err != nil || !tkn.Valid || claims.Type != "access" { writeError(w, "Invalid token", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // Показательный защищённый обработчик func profileHandler(w http.ResponseWriter, r *http.Request) { claims := &Claims{} jwt.ParseWithClaims(tokenFromRequest(r), claims, func(t *jwt.Token) (interface{}, error) { return jwtKey, nil }, jwt.WithValidMethods([]string{"HS256"}), ) var user User err := db.QueryRow( "SELECT id, username, phone, name, lastname, icons FROM users WHERE phone=$1", claims.Phone, ).Scan(&user.ID, &user.Username, &user.Phone, &user.Name, &user.Lastname, &user.Icons) if err != nil { writeError(w, "User not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) }
Собираем маршруты
func main() { initDB() defer db.Close() r := mux.NewRouter() r.HandleFunc("/register", registerHandler).Methods("POST") r.HandleFunc("/login", loginHandler).Methods("POST") r.HandleFunc("/refresh", refreshHandler).Methods("POST") r.HandleFunc("/logout", logoutHandler).Methods("POST") r.Handle("/profile", validateToken(http.HandlerFunc(profileHandler))).Methods("GET") c := cors.New(cors.Options{ AllowedOrigins: []string{"http://localhost:5173"}, AllowCredentials: true, // обязательно, чтобы ходили cookie AllowedHeaders: []string{"Authorization", "Content-Type"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, }) log.Println("Сервер запущен на :8080") log.Fatal(http.ListenAndServe(":8080", c.Handler(r))) }
Пара слов про CORS. Браузер по умолчанию запрещает фронту на localhost:5173 делать запросы на бекенд на localhost:8080 — это разные origin. В разработке мы явно разрешаем origin фронта и включаем AllowCredentials — без этого cookie с refresh просто не будут уходить.
Фронтенд на Vue 3
Store: access‑token живёт в памяти
// store/store.ts import { createStore } from 'vuex' import { refreshApi } from '@/api/api' export default createStore({ state: { accessToken: null as string | null }, mutations: { setAccessToken(state, token: string) { state.accessToken = token }, clearAccessToken(state) { state.accessToken = null }, }, actions: { async refreshToken({ commit }) { try { const newToken = await refreshApi() commit('setAccessToken', newToken) } catch (error) { console.error('Ошибка обновления токена:', error) commit('clearAccessToken') localStorage.removeItem('isAuthenticated') } }, }, getters: { accessToken: (s) => s.accessToken }, })
В localStorage при этом кладём не токен, а безобидный флаг isAuthenticated — просто отметку «пользователь логинился», по которой роутер понимает, пытаться ли восстанавливать сессию.
Логин
Вся суть страницы логина — в обработчике отправки формы:
async handleSubmit() { const userData = { phone: this.phone, password: this.password } try { const res = await loginApi(userData) this.$store.commit('setAccessToken', res.access_token) // токен — в память localStorage.setItem('isAuthenticated', 'true') // флаг — в localStorage this.$router.push({ name: 'ChatList' }) } catch (error) { toast.error(error.message) } }
Проблема перезагрузки страницы и её решение
Теперь столкнёмся с двумя проблемами. Во‑первых, access живёт всего минуту — значит, нужен /refresh (мы его уже сделали). Во‑вторых, при перезагрузке страницы store очищается, и access‑token пропадает. Пользователь был авторизован — и вдруг 401.
Решаем через флаг: если флаг стоит, а токена в памяти нет — значит, страницу перезагрузили, и сессию надо восстановить через refresh:
// router.ts router.beforeEach(async (to, from, next) => { const publicPages = ['Login', 'Register'] const isPublic = publicPages.includes(to.name as string) const isAuth = localStorage.getItem('isAuthenticated') === 'true' if (!isAuth && !isPublic) return next({ name: 'Login' }) // Токена в памяти нет, но пользователь логинился — восстанавливаем сессию. // Делаем это только когда токена действительно нет, а не на каждой навигации. if (isAuth && !isPublic && !store.getters.accessToken) { try { await store.dispatch('refreshToken') } catch { return next({ name: 'Login' }) } } next() })
Проблема «отошёл попить чай»
Пользователь может оставить вкладку открытой дольше жизни access‑токена и продолжить пользоваться приложением — например, менять аватарку. Чтобы не выбрасывать его с 401 посреди работы, перехватываем ответ axios и повторяем запрос с новым токеном:
// services/apiClient.ts import axios from 'axios' import store from '@/store/store' const apiClient = axios.create({ baseURL: 'http://localhost:8080', timeout: 10000, withCredentials: true, // чтобы уходила cookie с refresh }) // Автоматически подставляем токен apiClient.interceptors.request.use((config) => { const token = store.getters.accessToken if (token) config.headers.Authorization = `Bearer ${token}` return config }) // При 401 — обновляем токены и повторяем запрос один раз apiClient.interceptors.response.use( (response) => response, async (error) => { const original = error.config if (error.response?.status === 401 && !original._retry) { original._retry = true try { await store.dispatch('refreshToken') return apiClient.request(original) } catch (refreshError) { return Promise.reject(refreshError) } } if (error.response?.data?.result) { return Promise.reject({ message: error.response.data.result }) } return Promise.reject(error.message || 'Неизвестная ошибка') } ) export default apiClient
Флаг _retry защищает от бесконечного цикла обновлений, если refresh тоже протух. Для пользователя всё проходит незаметно: запрос один раз «фейлится», interceptor обновляет токены и повторяет его уже с валидным access.
Некоторые реализации вместо этого запускают таймер и обновляют токены превентивно каждые N минут. Мне такой вариант кажется лишней нагрузкой: при активном пользователе таймер крутится постоянно, а формат с повторным запросом срабатывает только тогда, когда это действительно нужно.
API‑слой
Обратите внимание: /refresh дёргаем «голым» fetch, а не apiClient — иначе попадём в собственный interceptor и получим цикл:
// api/api.ts const loginApi = async (data: LoginData) => { const response = await apiClient.post('/login', data, { headers: { 'Content-Type': 'application/json' }, }) return response.data } const refreshApi = async () => { const response = await fetch('http://localhost:8080/refresh', { method: 'POST', credentials: 'include', // отправляем cookie с refresh }) if (!response.ok) throw new Error('Не удалось обновить токен') const data = await response.json() return data.access_token } const profileApi = async () => (await apiClient.get('/profile')).data const logoutApi = async () => (await apiClient.post('/logout')).data
К этому моменту сессия живёт сразу в трёх местах: cookie с refresh (бекенд), access‑token в store (фронтенд) и флаг isAuthenticated в localStorage (фронтенд). Чтобы разлогиниться полностью, вычищаем все три:
Вызываем
logoutApi()— бекенд удаляет HttpOnly‑cookie; сам фронтенд это сделать не может.commit('clearAccessToken')— очищаем access‑token в store.localStorage.removeItem('isAuthenticated')— убираем флаг, чтобы роутер при следующем переходе не пытался восстановить сессию через/refresh.
Пара слов для тех, кто с Vuex не сталкивался: commit — это инструмент Vuex для изменения состояния store. State не меняют напрямую из компонента: в store объявляются мутации — единственные функции, которым разрешено менять состояние, — а компонент вызывает их по имени через commit:
// в store.ts объявлена мутация mutations: { clearAccessToken(state) { state.accessToken = null }, } // в компоненте вызываем её this.$store.commit('clearAccessToken')
Не путайте с dispatch — тот вызывает actions, асинхронные операции с запросами к серверу (как наш refreshToken). При логауте нам не нужно ничего асинхронного в состоянии — просто обнулить поле — поэтому commit, а не dispatch.
Если пропустить любой из трёх пунктов — получим «зомби‑сессию»: либо запросы продолжат ходить со старым токеном до перезагрузки страницы, либо роутер попытается восстановить сессию при следующем клике. Вычистили всё — редиректим пользователя на страницу логина.
Итоговая схема целиком: логинимся → держим access в store → при перезагрузке восстанавливаем его через /refresh по флагу → при 401 во время работы молча обновляемся и повторяем запрос → при логауте бекенд удаляет cookie, фронт чистит store и флаг.
Почему это безопаснее, чем localStorage
Две главные угрозы, о которых стоит помнить:
XSS — вредоносный скрипт попадает на страницу (например, через неэкранированный комментарий). Если токен лежит в localStorage, скрипт его читает и отправляет атакующему. Если это refresh — злоумышленник получает доступ на месяцы. HttpOnly‑cookie так не прочитать.
CSRF — чужой сайт делает запрос к вашему бекенду от имени пользователя: браузер прикладывает cookie автоматически. От этого нас страхует
SameSite=Strict— с чужого сайта cookie просто не уйдёт.
Честное замечание: хранение access в памяти — не серебряная пуля от XSS (скрипт, оказавшийся на странице, теоретически может дотянуться до store). Смысл в том, что токен не лежит в постоянном хранилище и умирает вместе с вкладкой, а главная защита — короткий TTL access‑токена и отсутствие XSS как такового.
Что стоит учесть в реальном проекте
Наша схема хороша для обучения, но в реальном проекте нужно учесть ещё несколько моментов. Объясню каждый простыми словами.
Токен нельзя «отозвать» мгновенно. JWT — как бумажный билет: его выпустили, и до конца срока действия он работает, кнопки «отменить» нет. Поэтому даже после логаута украденный access‑token ещё несколько минут будет работать. В боевых проектах с этим борются: хранят список отозванных токенов («чёрный список») либо держат refresh в базе данных и отключают его при логауте.
Refresh стоит делать одноразовым. Мы при каждом обновлении выдаём новую пару токенов. Правильно при этом ещё и инвалидировать старый refresh: если кто‑то попытается использовать его второй раз — почти наверняка токен украли, и нужно сбрасывать все сессии пользователя. Как одноразовый пароль: воспользовался один раз, а при повторной попытке — тревога.
Ключи не в коде. В примере ключи зашиты прямо в код — для статьи это нормально, но в реальном проекте их выносят в переменные окружения (env) — специальные настройки сервера, которые не лежат в репозитории. Иначе любой, кто получит доступ к коду, получит и ключи от всех токенов.
SameSite=Strict строгий, иногда слишком. С ним cookie не отправится, если пользователь пришёл по внешней ссылке — например, кликнул по ссылке в письме на ваш сайт. Для бекенда он окажется «не залогиненным», пока страница не перезапросит токены. Более мягкий режим Lax пропускает cookie при таких переходах, но тогда стоит добавить защиту от CSRF — отдельный токен, который подтверждает, что запрос действительно пришёл с вашего сайта.
Токен — это открытка. Об этом уже было выше, но повторю: не кладите в токен пароли и любые секреты. Всё, что лежит внутри токена, может прочитать кто угодно.
Итоги
Мы собрали полную схему авторизации: разобрали, как устроен JWT внутри (Base64Url + HMAC SHA‑256), зачем access и refresh подписываются разными ключами и имеют разные типы, реализовали регистрацию, логин, логаут и ротацию токенов на Go. На фронтенде — access‑token в переменной (store), восстановление сессии после перезагрузки через флаг + /refresh, защищённые роуты и незаметное повторение запросов при 401 через axios‑interceptor.
Концепция переносится на любой фронтенд‑стек: во Vue 3 это router.beforeEach и Vuex, в React/Next.js — свои guard‑механизмы, но логика остаётся той же. Надеюсь, статья сэкономила вам время и уберегла от пары граблей. Спасибо, что дочитали!