OAuth 2.0 описывает, как приложение получает ограниченный доступ к данным пользователя в другом сервисе, не узнавая его пароль. Вместо логина и пароля оно получает от сервера авторизации токен доступа: значение с набором разрешений и сроком действия, которое API сервиса примет вместо учётных данных.

- Что такое OAuth 2.0 простыми словами
- OAuth 2.0 и OpenID Connect: авторизация или аутентификация
- Роли в OAuth 2.0
- Регистрация приложения у провайдера OAuth
- Как работает OAuth 2.0: Authorization Code по шагам
- PKCE: как сгенерировать code_verifier и code_challenge
- Разбор ответа OAuth-сервера: код, state и ошибки
- Обмен кода на токен: учебный сервер на Python
- Токены OAuth: access token и refresh token
- Scope: какой доступ получает приложение
- Grant types: какой поток OAuth 2.0 выбрать
- Безопасность OAuth 2.0: что проверить в своей интеграции
- OAuth 2.1: что изменится
- Частые вопросы об OAuth 2.0
- Чем OAuth 2.0 отличается от OAuth 1.0?
- Можно ли хранить client_secret в мобильном приложении?
- Нужен ли PKCE, если у приложения есть client_secret?
- Сколько живёт access token?
Что такое OAuth 2.0 простыми словами
Возьмём сервис печати фотографий, которому нужны ваши снимки из облачного альбома. Без протокола авторизации у него один способ получить доступ: попросить логин и пароль от альбома напрямую. Тогда сторонний сервис хранит ваш пароль и получает полный доступ ко всему содержимому аккаунта без ограничения по времени. Отозвать доступ у одного сервиса можно только сменой пароля, и заодно доступ потеряют все остальные приложения. Если сервис печати взломают, пароль утечёт вместе со всеми данными.
С OAuth 2.0 пользователь вводит пароль только на странице облачного сервиса. Тот спрашивает, разрешить ли сервису печати чтение фотографий, и после согласия выдаёт ему токен доступа, который позволяет читать альбом и ничего больше. У токена ограниченный срок жизни, и его можно отозвать, не трогая пароль.
По такому принципу работают кнопки «Войти через Google» и «Войти с Яндекс ID»: API Google и API Яндекс ID используют протокол OAuth 2.0. Спецификация вышла в RFC 6749 в октябре 2012 года и заменила OAuth 1.0. Обратной совместимости между версиями нет, реализация первой версии с OAuth 2 работать не будет.
OAuth 2.0 и OpenID Connect: авторизация или аутентификация
OAuth 2.0 отвечает на вопрос «что приложению можно делать от имени пользователя». Токен доступа предназначен серверу ресурсов, а для клиентского приложения он обычно непрозрачная строка. Чтобы узнать, кто именно вошёл, нужен OpenID Connect.
OpenID Connect (OIDC) добавляет поверх OAuth 2.0 слой аутентификации. Приложение запрашивает scope openid и вместе с токеном доступа получает ID token: JWT с подписью, который содержит идентификатор пользователя у этого провайдера (sub), сведения о том, кто его выдал и для какого клиента. Без openid в scope поведение сервера не определено.
| OAuth 2.0 | OpenID Connect | |
|---|---|---|
| Задача | авторизация: доступ к API | аутентификация: кто вошёл |
| Что получает клиент | access token, иногда refresh token | то же плюс ID token |
| Формат главного токена | не задан, обычно непрозрачная строка | ID token всегда JWT с подписью |
| Информация о пользователе | через API сервиса | в ID token и через эндпоинт UserInfo |
| Защита от CSRF и повтора | state, PKCE | плюс nonce в ID token |
Если нужен вход на сайт через внешний аккаунт, лучше взять OIDC. Если нужно читать почту, календарь или файлы пользователя, хватит OAuth 2.0.
Роли в OAuth 2.0
Спецификация OAuth 2.0 определяет четыре роли:
- владелец ресурса (resource owner), тот, кто может предоставить доступ к данным; если это человек, его называют конечным пользователем;
- клиент (client), приложение, которое получает доступ к данным от имени владельца и с его разрешения: серверное веб-приложение, SPA в браузере, мобильное или десктопное приложение;
- сервер авторизации (authorization server), он проверяет пользователя, запрашивает согласие и выдаёт токены;
- сервер ресурсов (resource server), API, где лежат данные; он принимает запросы с токеном доступа.
Сервер авторизации и сервер ресурсов могут быть одной системой или работать на разных машинах. Один сервер авторизации может обслуживать несколько API.
Клиенты делятся на два вида. Конфиденциальный клиент умеет хранить секрет: например, приложение на сервере, к которому у посторонних нет доступа. Публичный не умеет: SPA, мобильные и десктопные приложения выполняются на устройстве пользователя.
Регистрация приложения у провайдера OAuth

До первого запроса авторизации клиент регистрируется у провайдера, обычно через форму в консоли разработчика. Вы указываете вид клиента и redirect URI, адрес, куда сервер вернёт пользователя после входа. В ответ провайдер предоставляет:
client_id, уникальный идентификатор приложения; он не секрет и доступен пользователю, поэтому одногоclient_idдля проверки клиента недостаточно;client_secret, секретный ключ для конфиденциальных клиентов; держите его на сервере, в переменных окружения или хранилище секретов, но не во фронтенде.
Провайдер должен сравнивать redirect URI из запроса авторизации с зарегистрированным посимвольно. Исключение одно: порт у адреса localhost в нативных приложениях. Лишний слеш в конце или http вместо https дадут ошибку, и это правильное поведение: так код авторизации не уйдёт на чужой адрес.
Как работает OAuth 2.0: Authorization Code по шагам
Authorization Code (код авторизации) сейчас используется как основной поток OAuth 2.0 для приложений, где есть пользователь. По шагам:
- Приложение перенаправляет пользователя на страницу авторизации провайдера с параметрами
response_type=code,client_id,redirect_uri,scope,stateи, с PKCE,code_challenge. - Пользователь входит в свой аккаунт на стороне провайдера и видит запрос на разрешение доступа к своим данным. Пароль клиент не видит.
- После согласия провайдер возвращает браузер на redirect URI с одноразовым кодом и тем же
stateв строке запроса. Код авторизации живёт недолго, рекомендуемый максимум 10 минут. - Приложение отправляет POST-запрос на эндпоинт токена:
grant_type=authorization_code, код,redirect_uri,client_idиcode_verifier, а конфиденциальный клиент ещё и свой секрет. Тело запроса передаётся в форматеapplication/x-www-form-urlencoded. - Сервер авторизации проверяет код и возвращает JSON:
access_token,token_type,expires_inв секундах и, если сервер их выдаёт,refresh_token. - Приложение обращается к API с заголовком
Authorization: Bearer <токен>.
Оба адреса сервера авторизации работают только по HTTPS: через них передаются учётные данные и токены. Код авторизации привязан к client_id и redirect URI, использовать его можно один раз. Если код пришёл повторно, сервер обязан отказать и по возможности отозвать токены, выданные по этому коду.

PKCE: как сгенерировать code_verifier и code_challenge
PKCE (Proof Key for Code Exchange) придумали для защиты от перехвата кода авторизации. В мобильных приложениях redirect URI может вести на свою схему вроде myapp://callback, и вредоносное приложение может зарегистрироваться обработчиком той же схемы. Без этой защиты перехваченного кода хватает, чтобы получить токен.
С PKCE клиент перед каждым запросом авторизации создаёт случайный ключ code_verifier длиной 43-128 символов из латинских букв, цифр и знаков -._~. В запрос авторизации уходит только его хеш SHA-256 в base64url, code_challenge. Сам ключ клиент отправляет позже, при обмене кода на токен, и сервер сверяет хеш. Перехватчик видит код и хеш, но ключа не знает. Подобрать ключ по результату хеш-функции на практике нельзя: он случайный и достаточно длинный для того, чтобы перебор не имел смысла.
# Python 3.14, только стандартная библиотека
import base64
import hashlib
import secrets
def make_verifier():
# 32 случайных байта в base64url без "=" дают 43 символа
return secrets.token_urlsafe(32)
def make_challenge(value):
digest = hashlib.sha256(value.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
v = make_verifier()
print(len(v), len(make_challenge(v)))
# контрольный пример из RFC 7636
print(make_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))
Вывод:
43 43
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
Вторая строка совпадает с эталонным значением из контрольного примера, значит, функция считает правильно. Если использовать base64.b64encode вместо urlsafe_b64encode, на том же примере получится E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw+cM=: плюс вместо дефиса и знак = в конце. С вычисленным на сервере значением такой результат не совпадёт. Метод plain, где хеш не считают и отправляют ключ как есть, остался для совместимости с уже работающими реализациями, в новых его применять не следует; если клиент умеет S256, он обязан использовать S256. Параметр code_challenge_method=S256 не пропускайте: без него сервер считает метод равным plain.
В рабочем проекте ключ и state создают заново для каждого входа, например make_verifier() и secrets.token_urlsafe(16), и хранят в сессии пользователя до возврата с сервера авторизации. Постоянное значение сводит защиту на нет: challenge обязан быть своим у каждого запроса.

Разбор ответа OAuth-сервера: код, state и ошибки

На redirect URI приходят code и state, а в случае отказа или неверного запроса параметр error. Перед обменом кода авторизации проверьте state. Он нужен для защиты от CSRF-атак, при которых злоумышленник через подложную ссылку отправляет на ваш адрес свой код, и приложение работает с ресурсами атакующего вместо ресурсов жертвы. Опираться только на PKCE можно, если вы точно знаете, что сервер его проверяет. Если не уверены, сравнивайте state.
from urllib.parse import urlparse, parse_qs
REDIRECT_URI = "https://app.example.com/callback" # примерный адрес
def read_callback(url, expected_state):
query = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
if "error" in query:
raise PermissionError(query["error"])
if query.get("state") != expected_state:
raise ValueError("state не совпал, запрос не наш")
return query["code"]
print(read_callback(REDIRECT_URI + "?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", "xyz"))
for url in (REDIRECT_URI + "?code=SplxlOBeZQQYbYS6WxSbIA&state=evil",
REDIRECT_URI + "?error=access_denied&state=xyz"):
try:
read_callback(url, "xyz")
except (ValueError, PermissionError) as e:
print(type(e).__name__, e)
Вывод:
SplxlOBeZQQYbYS6WxSbIA
ValueError state не совпал, запрос не наш
PermissionError access_denied
Ошибку проверяем раньше, чем state: в ответе с error кода нет, и обращение к query["code"] упало бы с KeyError. access_denied значит, что пользователь или сервер отказали в доступе. Покажите понятное сообщение и не повторяйте запрос автоматически.
Обмен кода на токен: учебный сервер на Python
Чтобы увидеть принцип работы сервера авторизации на этапе обмена, поднимем его в том же процессе на http.server. Он принимает POST /token, выдаёт и обновляет токены, данные хранит в словарях в памяти. Стенд нужен для понимания протокола OAuth. В продакшене свой сервер авторизации писать не стоит: используйте OAuth-провайдера или готовый сервер с открытым кодом, например Keycloak, он поддерживает OAuth 2.0, OpenID Connect и SAML.

import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
CLIENT_ID = "demo-client" # примерное значение
DEMO_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" # из RFC 7636
GRANTS = {c: {"client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
"challenge": make_challenge(DEMO_VERIFIER)}
for c in ("SplxlOBeZQQYbYS6WxSbIA", "Qx7pWm2LkT9vRc4YbN8sZA")}
REFRESH = {}
def issue(form):
if form.get("grant_type") == "authorization_code":
grant = GRANTS.pop(form.get("code"), None) # одноразовый
ok = grant and (grant["client_id"], grant["redirect_uri"], grant["challenge"]) == (
form.get("client_id"), form.get("redirect_uri"),
make_challenge(form.get("code_verifier", "")))
elif form.get("grant_type") == "refresh_token":
grant = REFRESH.pop(form.get("refresh_token"), None) # старый больше не примут
ok = grant and grant["client_id"] == form.get("client_id")
else:
return 400, {"error": "unsupported_grant_type"}
if not ok:
return 400, {"error": "invalid_grant"}
refresh = secrets.token_urlsafe(32)
REFRESH[refresh] = grant
return 200, {"access_token": secrets.token_urlsafe(32), "token_type": "Bearer",
"expires_in": 900, "refresh_token": refresh}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers["Content-Length"])).decode()
status, body = issue({k: v[0] for k, v in parse_qs(raw).items()})
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
TOKEN_URL = f"http://127.0.0.1:{server.server_port}/token"
Порт 0 значит «любой свободный», поэтому пример не конфликтует с другими программами. GRANTS.pop удаляет код авторизации при первой же попытке обмена, удачной или нет, и код становится одноразовым. Срок expires_in в 900 секунд выбран для примера: сколько он действует в реальной системе, решает провайдер.
Теперь клиент. urlopen с параметром data отправляет POST и сам ставит заголовок Content-Type: application/x-www-form-urlencoded. Ответы с ошибкой он выбрасывает как HTTPError, их тоже читаем как JSON.
from urllib.parse import urlencode
from urllib.request import urlopen
from urllib.error import HTTPError
def post(form):
try:
with urlopen(TOKEN_URL, data=urlencode(form).encode()) as resp:
return resp.status, json.load(resp)
except HTTPError as err:
return err.code, json.load(err)
exchange = {"grant_type": "authorization_code", "code": "SplxlOBeZQQYbYS6WxSbIA",
"redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID,
"code_verifier": DEMO_VERIFIER}
status, pair = post(exchange)
print("1.", status, pair["token_type"], pair["expires_in"], len(pair["access_token"]))
print("2.", post(exchange)) # тот же код второй раз
stolen = dict(exchange, code="Qx7pWm2LkT9vRc4YbN8sZA", code_verifier=make_verifier())
print("3.", post(stolen)) # перехват, чужой ключ
renew = {"grant_type": "refresh_token", "refresh_token": pair["refresh_token"],
"client_id": CLIENT_ID}
status, fresh = post(renew)
print("4.", status, fresh["refresh_token"] != pair["refresh_token"])
print("5.", post(renew)) # старый refresh token
Вывод на Python 3.14.3:
1. 200 Bearer 900 43
2. (400, {'error': 'invalid_grant'})
3. (400, {'error': 'invalid_grant'})
4. 200 True
5. (400, {'error': 'invalid_grant'})
Значения случайные, поэтому печатаем тип, срок и длину. Строки по порядку:
- Обмен прошёл: ключ из запроса на токен дал тот же хеш, что пришёл в запросе авторизации.
- Тот же код второй раз:
invalid_grant. Тем же кодом ошибки сервер отвечает на неверныйredirect_uri, чужойclient_idи истёкший код. - Атакующий перехватил второй код, но ключ у него свой. Хеш не совпал, обмен отклонён.
- Обновление выдало новую пару, и токен обновления в ней другой.
- Старый токен обновления после ротации не принимается.

Токены OAuth: access token и refresh token
Токен доступа (access token) приложение отправляет в API с каждым запросом. Внутри может быть просто идентификатор записи на сервере, а может быть самодостаточная запись с данными и подписью, например JWT. Время жизни приходит в expires_in в секундах, 3600 значит один час с момента ответа. Формат определяет провайдер, поэтому разбирать токен в клиенте не стоит: приложение просто передаёт его в API.
Передают его в заголовке Authorization: Bearer .... В строку запроса URL его не кладут: адреса оседают в истории браузера и логах веб-сервера. Как такой заголовок принимает сервер, мы разбирали в статье о REST API. На просроченный или отозванный токен сервер ресурсов отвечает 401 с invalid_token, на нехватку прав 403 с insufficient_scope.
Токен обновления (refresh token) позволяет получить новый токен доступа без участия пользователя, когда старый истёк. Его отправляют только на сервер авторизации, в API он не ходит. Выдавать ли его вообще, решает провайдер. В ответ на запрос Client Credentials его не включают. У провайдера бывают свои лимиты: у Google на один аккаунт и один client ID действует не больше 100 refresh token, и при выдаче нового сверх лимита самый старый без предупреждения перестаёт работать.
Для публичных клиентов токен обновления должен быть привязан к клиенту криптографически или меняться при каждом обновлении, как в нашем стенде. При ротации украденное значение рано или поздно предъявят повторно, провайдер заметит это и отзовёт активный токен. Пользователю придётся войти заново, зато атакующий доступ потеряет.

Scope: какой доступ получает приложение
Scope (область доступа) перечисляет права через пробел: profile email. Значения чувствительны к регистру и определяются сервером авторизации, поэтому список берут из документации провайдера. Пользователь видит на экране согласия, к чему приложение запрашивает доступ, а выданный доступ ограничен этими рамками.
Запрашивайте только те права, которые нужны приложению для конкретного сценария. Приложению, которое показывает аватар, не нужен доступ на запись в файлы.
Grant types: какой поток OAuth 2.0 выбрать
Спецификация OAuth 2.0 описывает четыре способа получения токена (grant type) и механизм расширений. С тех пор появились новые потоки, а два старых признаны небезопасными.
| Поток | Для чего | Статус |
|---|---|---|
| Authorization Code + PKCE | веб-приложения с серверной частью, SPA, мобильные и десктопные приложения | основной вариант; для публичных клиентов PKCE обязателен, для конфиденциальных рекомендован |
| Client Credentials | сервер к серверу, без пользователя: доступ к своим ресурсам клиента | только для конфиденциальных клиентов |
| Device Authorization | телевизоры, приставки, принтеры: нет браузера или неудобно вводить текст | пользователь подтверждает доступ на другом устройстве |
| Refresh Token | обновление токена доступа | для публичных клиентов с ротацией или привязкой |
| Implicit | токен сразу в redirect URI, для старых SPA | не рекомендуется: токен уязвим к утечке и повторному использованию |
| Resource Owner Password Credentials | приложение получает учётные данные пользователя, логин и пароль | запрещён: пароль попадает клиенту, поток не рассчитан на двухфакторную аутентификацию |

Мобильному приложению страницу авторизации нужно открывать в системном браузере. Встроенный WebView для запроса авторизации запрещён спецификацией для нативных приложений.
Безопасность OAuth 2.0: что проверить в своей интеграции
Перед запуском интеграции с OAuth пройдитесь по списку:
- все адреса авторизации и выдачи токенов работают только по HTTPS;
- redirect URI зарегистрированы полностью, без масок, и на сайте нет открытых редиректов, которые переадресуют на адрес из параметра запроса;
- токены не попадают в URL и в логи;
- доступ минимальный: в scope только нужные права, токен ограничен нужным API;
- на страницах приложения нет XSS-уязвимостей: внедрённый скрипт выполняется с правами пользователя и отправляет запросы от его имени;
- если SPA обращается к API на другом домене, на сервере API настроены заголовки CORS: браузер проверяет их отдельно от OAuth.
Мы бы не стали использовать в новом проекте ни Implicit, ни Password: для обоих сценариев есть Authorization Code с PKCE.

OAuth 2.1: что изменится
OAuth 2.1 пока не стандарт. На 9 октября 2026 года это черновик draft-ietf-oauth-v2-1-16 от 2 сентября 2026 года, окончательную версию планируют отправить на утверждение в декабре 2026-го. Черновик сводит в один документ OAuth 2.0, PKCE, правила для нативных и браузерных приложений, использование bearer-токенов и поздние рекомендации по безопасности:
- PKCE входит в Authorization Code по умолчанию, метод
plainубран; - redirect URI сравниваются точно, посимвольно;
- Implicit и Password из спецификации исключены;
- bearer-токены нельзя передавать в строке запроса URL;
- refresh token публичного клиента привязан к клиенту или одноразовый;
- при обмене кода на токен параметр
redirect_uriбольше не передаётся.
Частые вопросы об OAuth 2.0
Чем OAuth 2.0 отличается от OAuth 1.0?
Версии несовместимы, общих деталей реализации у них очень мало. В OAuth 1.0 клиент подтверждает авторизованный запрос подписью (методы HMAC-SHA1, RSA-SHA1 или PLAINTEXT). В OAuth 2.0 используются bearer-токены: кто владеет токеном, тот и может им пользоваться, поэтому передают его только по HTTPS. OAuth 1.0 спецификация второй версии оставляет для уже работающих систем, новые интеграции делают на OAuth 2.0.
Можно ли хранить client_secret в мобильном приложении?
Нет. Мобильное приложение относится к публичным клиентам OAuth: сохранить секрет в тайне оно не может. Такие приложения регистрируют без секрета и защищают обмен кода с помощью PKCE.
Нужен ли PKCE, если у приложения есть client_secret?
Рекомендуется. Для конфиденциальных клиентов, например серверных приложений с секретом, PKCE защищает от подмены и повторного использования кода авторизации и заодно закрывает CSRF, если сервер его проверяет. В черновике OAuth 2.1 он часть Authorization Code для всех клиентов.
Сколько живёт access token?
Единого срока нет, его задаёт сервер авторизации и сообщает в expires_in. Код авторизации, в отличие от него, по рекомендации спецификации живёт не дольше 10 минут.








