The user proves their identity with their own key, and your backend receives a cryptographically verified identity: full name, RNOKPP, EDRPOU. A challenge–response scheme — the signature cannot be forged or replayed.
Why not a JS library
QES authentication where the signature is verified by a JavaScript library in the browser is not a production-safe solution:
- the verification result is produced on the client side — an attacker controls the browser and can fake "the signature is valid" without any key at all;
- open pure-JS DSTU implementations have known classes of vulnerabilities:
timing attacks (non-constant-time cryptography) and weak generation of the
random
k, which makes it possible to recover the private key from signatures.
DSTUcrypt closes both problems: the signature is produced in an isolated iframe on our origin (WASM, constant time), and verification runs on our server with a native crypto core. Nothing from the browser is trusted except the signature itself.
The flow (4 steps)
your backend ──1─▶ POST /api/auth/challenge ─▶ { challenge }
your page ─2─▶ auth widget: the user signs the challenge with their key
your page ─3─▶ signature → your backend
your backend ──4─▶ POST /api/auth/verify {challenge, signature}
─▶ { ok, subject{…} }
1. The backend obtains a one-time challenge
curl -X POST https://dstucrypt.com.ua/api/auth/challenge
# → { "challenge": "dstucrypt-login-v1:…", "expiresIn": 300 }
The challenge lives for 5 minutes and is burned after the first verification (replay protection).
2–3. The page: the widget signs the challenge
import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';
const auth = await embed('auth', { mount: 'modal' });
const { signature, subject, accredited } = await auth.login({ challenge });
// subject/accredited here are for UX ONLY (show "Welcome, Ivan!" / warn
// that the key is not a QES); only the /api/auth/verify response can be trusted (step 4)
await fetch('/api/my-backend/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge, signature }), // signature — a base64 string
});
4. The backend verifies the signature with us
curl -X POST https://dstucrypt.com.ua/api/auth/verify \
-H "Content-Type: application/json" \
-d '{ "challenge": "dstucrypt-login-v1:…", "signature": "<base64>" }'
Success:
{
"ok": true,
"subject": {
"fullName": "Ivanenko Ivan Ivanovych",
"taxId": "1234567890",
"orgCode": "12345678",
"issuer": "ACSK …",
"certSerial": "…",
"validFrom": "2025-01-01 00:00:00",
"validTo": "2027-01-01 00:00:00"
},
"signatureFormat": "CAdES-BES",
"signingTime": "2026-07-31 10:00:00",
"status": "TOTAL-VALID",
"accredited": true, // certificate from an accredited CA
"qualified": true // this is a QES login (accredited + valid)
}
Create your own session for the user keyed by subject.taxId (RNOKPP) — it is a
stable identifier of the person. qualified: true means the login was made specifically with a QES;
if your scenario requires a QES only, reject qualified: false.
Failures
| HTTP | reason |
Cause |
|---|---|---|
| 401 | challenge_invalid_or_used |
the challenge is unknown, expired or already used |
| 200 | signature_invalid (ok:false) |
the signature failed the cryptographic check, the content ≠ challenge, or the certificate is not valid |
| 400 | bad_request / bad_signature_encoding |
malformed request body |
Login via an app: Diia.Signature and Smart ID
The same widget can confirm identity with a key from an app — Diia or
Privat24. The flow does not change: the same four steps, the same verification at
/api/auth/verify. The only thing that changes is what the user signs the challenge with.
const auth = await embed('auth', { providers: ['file', 'diia', 'smartid'] });
const res = await auth.login({ challenge });
// For Diia the challenge is set by OUR server — take the one that was actually signed
await fetch('/api/my-backend/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: res.challenge || challenge,
signature: res.signature,
}),
});
One Diia peculiarity
Diia requires the request identifier to be a GOST 34.311 digest of a
uuid v4 string. So for Diia the challenge is generated by our server, not your backend — and
returned in res.challenge. The signed content is the uuid string itself, so
/api/auth/verify checks it the usual way, with no differences whatsoever.
What this means for your code: take res.challenge || challenge.
For a file key and Smart ID the field is empty, so your own challenge is used;
for Diia — the one that was actually signed. One line covers all three methods.
Smart ID has no such peculiarity: it signs your challenge as-is, and in the app it shows login wording rather than document-signing wording.
When there is only one method
If you passed a single cloud method, the widget does not show the intermediate screen with the "Log in" button — it opens the QR code right away. There is nothing to choose from, and the user gives consent in the app anyway.
This is not done for a file key: there the user still has to pick the file and enter the password, and the click itself is deliberate consent to log in.
Things to keep in mind
- Login via a provider, like cloud signing, consumes our partner account — so it is available to paid domains (see Diia.Signature and Smart ID).
- The QR code lives for 3 minutes and is single-use.
- For a cloud method,
subjectfrom the widget is empty: the signer's data is returned by/api/auth/verify— authoritatively, with chain verification. That is what you should rely on.
Trust rules
- Trust only the response of
POST /api/auth/verify(server-to-server). subjectfrom the widget is for UX only.- Verification is performed by a native crypto core on our server: the signature, integrity, the content matching the challenge, and certificate validity.