Documentation · Widgets

Login with QES (user authentication)

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, subject from 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).
  • subject from 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.