Integration security
Updated 27.10.2026
What to do on your side so that integration with VISH does not become a hole: it contains keys, tokens, and student data. The list is checked against how it works on October VISH, 2026, and with the recommendations of OAuth (RFC 9700).
API Keys
- The key is only on the server. Do not insert it into the page code, mobile application, or browser extension: anyone can get it from there. If the program is running for the user, it does not need your key, but an “entrance through VISH”.
- Keep it in secrets, not in a repository: environment variable, secret repository (Lockbox, Vault, CI secrets). The key starts with
vish_. Using this prefix, it is easy to find it by searching for leaks. - Minimum rights. When releasing, mark only what the program needs. You don't need a key at all to read public data.
- Term. Set a reasonable end date; a self-issued key is required (no longer than 730 days).
- If it lights up, re—release it with the Re-Release button on the “developer” page: the old line stops working immediately.
- Не пишите в журналы заголовки
X-Api-KeyиAuthorization, адреса с параметрами и тела ответов с личными данными.
“Log in via VISH”
- PKCE S256 is always available, and for closed applications too. VISH other options and does not accept.
state— random, your own for each input; check it in the return address before any other actions. This is a protection against login forgery (login CSRF).nonceis random, check fromnoncetoid_token: this way the ID token cannot be slipped from someone else's input.issв адресе возврата должен совпадать сissuerиз документа настройки — защита от путаницы между провайдерами (mix-up).- The exact return addresses. Register exactly the addresses you are returning to; VISH compares them character by character and does not allow
*. Do not make the return address a “universal redirector”. - Check
id_token:iss,aud— yourclient_id,nonce,exp. Signature (ES256) — using keys out ofjwks_uri, if the token did not come directly from the/oauth/tokenresponse. - Минимум разрешений. Просите только нужные
scope: человеку показывают весь список, и длинный список — повод отказать. - The way out is with a review. When a person logs out of your app, revoke the refresh token after
POST /oauth/revoke.
Where to store tokens
- The tokens are not in the address. Neither
access_tokennorrefresh_tokenshould be included in the page address,Refereror the browser history. VISH it issues them only in the response body and rejects the codes and tokens in the protocol request address. - Best of all, on the server (backend-for-frontend scheme): the browser only holds session cookies for your site with flags
HttpOnly,Secure,SameSite=Lax, and tokens VISH are stored on the server. - If it's just the browser, keep the tokens in the page's memory, and
code_verifierandstateinsessionStoragefor the login time. Do not put tokens inlocalStorage, especially on a site with third-party scripts: any script on the page will read them. - The Refresh token changes with each update. Save the new one and don't use the old one: reuse VISH counts as theft and revokes all input.
Your website
- CSRF. If your site uses cookies, protect changing requests with
SameSiteand a CSRF token (or verification ofOrigin). Addresses VISH are independent of this: VISH does not use cookies, the key and token are transmitted only by headers. - Open redirects. A parameter like
?returnTo=should be checked against the list of your addresses after logging in; do not redirect to anything from the address. - Clickjacking. Prohibit showing your login and confirmation pages in the frame:
Content-Security-Policy: frame-ancestors 'none'(orX-Frame-Options: DENY). The consent page VISH itself is so protected and does not open in the frame. - XSS. Texts from VISH (notes, titles) are data: escape them when outputting, and display Markdown notes through the sanitizer.
Personal information
A person's name, group, role, and schedule are personal data. Take only what you need, keep it no longer than necessary, and delete it when a person has disabled your app (after that, GET /oauth/userinfo responds with its tokens with invalid_token). Explain to people in your privacy policy what you get from VISH and why.
Practical verification
The example creates state and nonce, compares them in constant time, checks address returnTo from the list and makes sure that VISH rejects the code in the request address and does not allow the consent page to be shown in the frame.:
// State and nonce, constant-time comparison, a returnTo allowlist, and two defences VISH enforces.
import { randomBytes, timingSafeEqual } from 'node:crypto'
const API = process.env.VISH_API ?? 'https://d1.vish-app.ru/api/v1'
const state = randomBytes(32).toString('base64url')
const nonce = randomBytes(32).toString('base64url')
const same = (left, right) =>
typeof left === 'string' && left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right))
if (!same(state, state) || same('forged', state) || state === nonce) throw new Error('state check is wrong')
// Only our own pages after sign-in: no open redirect through ?returnTo=.
const ORIGIN = 'https://app.example.org'
// A backslash reads as a slash to browsers, and a result starting with // is another host.
const safeReturnTo = (value) => {
if (typeof value !== 'string' || value.includes('\\')) return '/'
const target = new URL(value, ORIGIN)
const path = target.pathname + target.search
return target.origin === ORIGIN && path.startsWith('/') && !path.startsWith('//') ? path : '/'
}
const hostile = ['https://evil.example/', '//evil.example', '/.//evil.example', '/\\evil.example', '/\\\\evil.example', '\\\\evil.example']
if (safeReturnTo('/timetable?week=8') !== '/timetable?week=8' || hostile.some((value) => safeReturnTo(value) !== '/')) {
throw new Error('returnTo check is wrong')
}
// Percent-encoded slashes stay a path of our own site.
if (safeReturnTo('%2F%2Fevil.example') !== '/%2F%2Fevil.example') throw new Error('returnTo check is wrong')
// VISH refuses a code or token in the URL of a protocol request.
const inUrl = await fetch(`${API}/oauth/token?code=leaked`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code' }),
})
const refusal = await inUrl.json()
if (inUrl.status !== 400 || refusal.error !== 'invalid_request') throw new Error(`code in URL: ${inUrl.status} ${JSON.stringify(refusal)}`)
// VISH's consent page cannot be framed.
const consent = await fetch(new URL('/oauth/authorize', API))
const policy = `${consent.headers.get('content-security-policy')} ${consent.headers.get('x-frame-options')}`
if (!/frame-ancestors 'none'|DENY/u.test(policy)) throw new Error('consent page may be framed')
console.log('state/nonce, returnTo and constant-time checks pass')
console.log('code in URL ->', inUrl.status, refusal.error)
console.log('consent page framing ->', consent.headers.get('x-frame-options'))We found a vulnerability
Write to us — the contact is listed at /.well-known/security.txt on the website VISH. Do not check the find on other people's accounts and production data: there is a stand d1 for experiments.
Verified: 2026-10, PWA aa337c45, platform bff65fce._