diff --git a/etc/config.py.example b/etc/config.py.example
index 72c464072e6127528eecf1a2a7af3773891d90e5..a93ece2d5805b5bba8c375acd4cd48cb32bfd020 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -47,3 +47,9 @@ JOB_GC_PERIOD = 60
 
 # Za jak dlouho expiruje dokončená dávka [min]
 JOB_EXPIRATION = 5
+
+# Kolik nejvýše dovolujeme registrací za minutu
+REG_MAX_PER_MINUTE = 10
+
+# Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
+REG_TOKEN_VALIDITY = 10
diff --git a/mo/users.py b/mo/users.py
index cb3b851fbede52197feb42e86b45a2b59740bb70..8a12debf0592feea0a28d3a139dba88057ee794f 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -5,9 +5,11 @@ import datetime
 import email.errors
 import email.headerregistry
 import re
+import secrets
 from typing import Optional, Tuple
 
 import mo
+import mo.config as config
 import mo.db as db
 import mo.util
 from mo.util import logger
@@ -243,3 +245,22 @@ def do_reset_password(user: db.User):
         what=user.user_id,
         details={'action': 'do-reset'},
     )
+
+
+def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]:
+    sess = db.get_session()
+
+    # Zatím jen jednoduchý rate limit, časem možno vylepšit
+    in_last_minute = db.get_count(sess.query(db.RegRequest).filter(db.RegRequest.created_at >= mo.now - datetime.timedelta(minutes=1)))
+    if in_last_minute >= config.REG_MAX_PER_MINUTE:
+        return None
+
+    email_token = mo.tokens.sign_token([str(int(mo.now.timestamp())), secrets.token_hex(16)], 'reg-request')
+
+    return db.RegRequest(
+        type=type,
+        created_at=mo.now,
+        expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY),
+        email_token=email_token,
+        client=client,
+    )
diff --git a/mo/util.py b/mo/util.py
index 3a0cb7689c47dc8021374a2c62ffe087acacde79..d047aee40027a6f11076e0a93eccb03c555b22e7 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -117,6 +117,10 @@ def password_reset_url(token: str) -> str:
     return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':')
 
 
+def confirm_create_url(token: str) -> str:
+    return config.WEB_ROOT + 'auth/confirm?' + urllib.parse.urlencode({'token': token}, safe=':')
+
+
 def send_new_account_email(user: db.User, token: str) -> bool:
     return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\
         Vítejte!
@@ -142,6 +146,18 @@ def send_password_reset_email(user: db.User, token: str) -> bool:
     '''.format(password_reset_url(token))))
 
 
+def send_confirm_create_email(user: db.User, token: str) -> bool:
+    return send_user_email(user, 'Založení účtu', textwrap.dedent('''\
+        Někdo (pravděpodobně Vy) požádal o založení účtu s touto e-mailovou adresou
+        v Odevzdávacím systému Matematické olympiády. Pokud účet chcete založit,
+        následujte tento odkaz:
+
+                {}
+
+        Váš OSMO
+    '''.format(confirm_create_url(token))))
+
+
 def die(msg: str) -> NoReturn:
     print(msg, file=sys.stderr)
     sys.exit(1)
diff --git a/mo/web/auth.py b/mo/web/auth.py
index 029dabb6b4332839f83b19f6d41f32525a4b9a40..e0d3b46b4be7a503506db94be5a9ce85c1ed8984 100644
--- a/mo/web/auth.py
+++ b/mo/web/auth.py
@@ -1,19 +1,26 @@
 import datetime
-
+import dateutil.tz
+from enum import Enum, auto
 from flask import render_template, request, g, redirect, url_for, session
 from flask.helpers import flash
 from flask_wtf import FlaskForm
+import html
+from markupsafe import Markup
+import random
+import secrets
+from sqlalchemy.orm import joinedload
+from typing import Optional, Dict
 import werkzeug.exceptions
 import wtforms
+from wtforms import validators, ValidationError
 from wtforms.fields.html5 import EmailField
-import wtforms.validators as validators
-from sqlalchemy.orm import joinedload
-from typing import Optional
 
-import mo.util
+import mo.config as config
 import mo.db as db
 import mo.rights
+import mo.tokens
 import mo.users
+import mo.util
 from mo.web import app, NeedLoginError
 import mo.web.fields as mo_fields
 
@@ -175,6 +182,306 @@ def reset():
         return login_and_redirect(user)
 
 
+class RegStatus(Enum):
+    new = auto()
+    ok = auto()
+    expired = auto()
+    already_exists = auto()
+    # Jen v 1. kroku:
+    rate_limited = auto()
+    wrong_captcha = auto()
+    # Jen v 2. kroku:
+    already_spent = auto()
+
+
+class Reg1:
+    create_time: datetime.datetime
+    seed: str
+    status: RegStatus
+    email_token: str
+    x: int
+    y: int
+
+    def __init__(self, from_token: Optional[str] = None):
+        self.status = self._parse_token(from_token)
+        if self.status == RegStatus.ok:
+            self._gen_captcha()
+        else:
+            self._reset()
+
+    def _reset(self):
+        self.create_time = mo.now
+        self.seed = secrets.token_hex(16)
+        app.logger.debug(f'Reg1: Nový token: seed={self.seed}')
+        self._gen_captcha()
+
+    def as_token(self) -> str:
+        return mo.tokens.sign_token([str(int(self.create_time.timestamp())), self.seed], 'reg1')
+
+    def _parse_token(self, token: Optional[str]) -> RegStatus:
+        if token is None:
+            return RegStatus.new
+
+        fields = mo.tokens.verify_token(token, 'reg1')
+        if not fields:
+            app.logger.debug(f'Reg1: Neplatný token: {token}')
+            return RegStatus.new
+
+        self.create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC)
+        self.seed = fields[1]
+        app.logger.debug(f'Reg1: Přijat token: {self.create_time} {self.seed}')
+
+        token_age = mo.now - self.create_time
+        if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY):
+            app.logger.debug('Reg1: Token expiroval')
+            return RegStatus.expired
+
+        return RegStatus.ok
+
+    def _init_rng(self) -> random.Random:
+        rng = random.Random()
+        rng.seed(mo.tokens.hash('rng-init', self.seed))
+        return rng
+
+    def _gen_captcha(self):
+        rng = self._init_rng()
+        self.x = rng.randrange(1, 10)
+        self.y = rng.randrange(1, 10)
+        app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}')
+
+    def captcha_task(self) -> str:
+        cisla = ['nula', 'jedna', 'dva', 'tři', 'čtyři', 'pět', 'šest', 'sedm', 'osm', 'devět',
+                 'deset', 'jedenáct', 'dvanáct', 'třináct', 'čtrnáct', 'patnáct', 'šestnáct', 'sedmnáct', 'osmnáct', 'devatenáct']
+        return f'Napište číslem, kolik je {cisla[self.x]} krát {cisla[self.y]}.'
+
+    def captcha_check_answer(self, answer: str) -> bool:
+        correct = self.x * self.y
+        if answer == str(correct):
+            app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} správně')
+            return True
+        else:
+            app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} špatně')
+            return False
+
+    def create_reg_request(self, email: str) -> bool:
+        sess = db.get_session()
+        rr = sess.query(db.RegRequest).with_for_update().filter_by(captcha_token=self.seed).one_or_none()
+        if rr:
+            self._reset()
+            self.status = RegStatus.expired
+            sess.rollback()
+            app.logger.info('Reg1: Captcha token použit znovu')
+            return False
+
+        rr = mo.users.new_reg_request(db.RegReqType.register, request.remote_addr)
+        if not rr:
+            self._reset()
+            self.status = RegStatus.rate_limited
+            sess.rollback()
+            app.logger.info('Reg1: Rate limit')
+            return False
+
+        self.email_token = rr.email_token
+        rr.email = email
+        rr.captcha_token = self.seed
+        sess.add(rr)
+        sess.commit()
+        return True
+
+    def process(self, email: str, captcha: str) -> bool:
+        # XXX: Nejdříve zapisujeme registraci do DB, a teprve pak ověřujeme captchu.
+        # Tímto způsobem je těžší captchu obejít (protože je rate-limitovaná), ale
+        # zase je snazší páchat DoS útok na celou registraci (protože je rate-limitovaná).
+
+        if not self.create_reg_request(email):
+            return False
+
+        if not self.captcha_check_answer(captcha):
+            self._reset()
+            self.status = RegStatus.wrong_captcha
+            return False
+
+        if mo.users.user_by_email(email):
+            self._reset()
+            self.status = RegStatus.already_exists
+            app.logger.info(f'Reg1: Účet s e-mailem {email} už existuje')
+            return False
+
+        return True
+
+
+class Reg1Form(FlaskForm):
+    email = mo_fields.Email(validators=[validators.DataRequired()])
+    token = wtforms.HiddenField()
+    captcha = wtforms.StringField('Kontrolní odpověď', validators=[validators.DataRequired()])
+    submit = wtforms.SubmitField('Vytvořit účet')
+
+
+@app.route('/auth/create', methods=('GET', 'POST'))
+def create_acct():
+    form = Reg1Form()
+    reg1 = Reg1(form.token.data)
+
+    if reg1.status == RegStatus.ok and form.validate_on_submit() and reg1.process(form.email.data, form.captcha.data):
+        app.logger.debug(f'Reg1: E-mailový token {reg1.email_token}')
+        flash('Odeslán e-mail s odkazem na založení účtu.', 'success')
+        user = db.User(email=form.email.data, first_name='Nový', last_name='Uživatel')
+        mo.util.send_confirm_create_email(user, reg1.email_token)
+        return redirect(url_for('confirm_reg'))
+
+    form.captcha.description = reg1.captcha_task()
+    if reg1.status != RegStatus.ok:
+        form.token.data = reg1.as_token()
+        form.captcha.data = ""
+
+    if reg1.status == RegStatus.expired:
+        flash('Vypršela platnost formuláře, vyplňte ho prosím znovu.', 'danger')
+    elif reg1.status == RegStatus.rate_limited:
+        flash('Přichází příliš mnoho registrací najednou, zkuste to prosím za chvíli znovu.', 'danger')
+    elif reg1.status == RegStatus.already_exists:
+        form.email.errors.append('Účet s touto adresou už existuje. ' + Markup('<a href="' + html.escape(url_for('login', email=form.email.data)) + '">Přihlásit se.</a>'))
+    elif reg1.status == RegStatus.wrong_captcha:
+        form.captcha.errors.append('Chybný výsledek. Zkuste to znovu: ' + reg1.captcha_task())
+
+    return render_template('acct_reg1.html', form=form)
+
+
+class Reg2:
+    reg_type: db.RegReqType
+    status: RegStatus
+    rr: db.RegRequest
+    user: db.User
+
+    messages: Dict[db.RegReqType, Dict[RegStatus, str]] = {
+        db.RegReqType.register: {
+            RegStatus.new: 'Chybný registrační kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.',
+            RegStatus.expired: 'Vypršela platnost registrace, vyplňte ji prosím znovu.',
+            RegStatus.already_spent: 'Tento odkaz na potvrzení registrace byl již využit.',
+            RegStatus.already_exists: 'Účet s touto adresou už existuje.',
+        },
+    }
+
+    def __init__(self, token: str, expected_type: db.RegReqType):
+        self.reg_type = expected_type
+        self.status = self._parse_token(token)
+        if self.status == RegStatus.ok:
+            self.status = self._load_rr(token)
+
+    def _parse_token(self, token: Optional[str]) -> RegStatus:
+        if not token:
+            return RegStatus.new
+
+        token = mo.util.clean_up_token(token)
+        fields = mo.tokens.verify_token(token, 'reg-request')
+        if not fields:
+            app.logger.debug(f'Reg2: Neplatný token: {token}')
+            return RegStatus.new
+
+        create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC)
+        app.logger.debug(f'Reg2: Přijat token: {token}')
+
+        token_age = mo.now - create_time
+        if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY):
+            app.logger.debug('Reg2: Token expiroval')
+            return RegStatus.expired
+
+        return RegStatus.ok
+
+    def _load_rr(self, token: str) -> RegStatus:
+        sess = db.get_session()
+        rr = sess.query(db.RegRequest).with_for_update().filter_by(email_token=token).one_or_none()
+        if not rr:
+            app.logger.info('Reg2: Registrace nenalezena')
+            return RegStatus.expired
+
+        if rr.expires_at < mo.now:
+            app.logger.info('Reg2: Registrace expirovala')
+            return RegStatus.expired
+
+        if rr.used_at is not None:
+            app.logger.info('Reg2: Registrace spotřebována')
+            return RegStatus.already_spent
+
+        if rr.type != self.reg_type:
+            app.logger.info('Reg2: Token špatného typu')
+            return RegStatus.new
+
+        self.rr = rr
+        return RegStatus.ok
+
+    def create(self, first_name: str, last_name: str, passwd: str) -> bool:
+        rr = self.rr
+        assert rr.email is not None
+        email = mo.users.normalize_email(rr.email)      # Pro jistotu
+        sess = db.get_session()
+
+        if db.get_session().query(db.User).with_for_update().filter_by(email=email).one_or_none():
+            # Účet mohl začít existovat mezi 1. a 2. krokem registrace
+            app.logger.info(f'Reg2: Účet s e-mailem {email} začal během registrace existovat')
+            self.status = RegStatus.already_exists
+            return False
+
+        user = db.User(
+            email=email,
+            first_name=first_name,
+            last_name=last_name,
+        )
+        mo.users.set_password(user, passwd)
+
+        rr.used_at = mo.now
+        sess.add(user)
+        sess.flush()
+
+        app.logger.info(f'Reg2: Založen uživatel user=#{user.user_id} email=<{user.email}>')
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={'action': 'register', 'new': db.row2dict(user)},
+        )
+
+        sess.commit()
+        self.user = user
+        return True
+
+    def flash_message(self):
+        msgs = self.messages[self.reg_type]
+        if self.status in msgs:
+            flash(msgs[self.status], 'danger')
+
+
+class Reg2Form(FlaskForm):
+    email = wtforms.StringField('E-mail', render_kw={"disabled": "disabled"})
+    first_name = mo_fields.FirstName(validators=[validators.DataRequired()])
+    last_name = mo_fields.LastName(validators=[validators.DataRequired()])
+    new_passwd = mo_fields.NewPassword('Heslo', validators=[validators.DataRequired()])
+    new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()])
+    submit = wtforms.SubmitField('Vytvořit účet')
+
+
+@app.route('/auth/confirm/r', methods=('GET', 'POST'))
+def confirm_reg():
+    token = request.args.get('token')
+    if token is None:
+        return render_template('acct_reg2.html', form=None)
+
+    reg2 = Reg2(token, db.RegReqType.register)
+    if reg2.status != RegStatus.ok:
+        reg2.flash_message()
+        return redirect(url_for('create_acct'))
+
+    form = Reg2Form()
+    if form.validate_on_submit():
+        if reg2.create(form.first_name.data, form.last_name.data, form.new_passwd.data):
+            flash('Založení účtu a přihlášení do systému proběhlo úspěšně.', 'success')
+            app.logger.info(f'Login: Přihlásil se uživatel <{reg2.user.email}> po založení účtu')
+            return login_and_redirect(reg2.user, flash_msg='Účet úspěšně založen.')
+
+    reg2.flash_message()
+    form.email.data = reg2.rr.email
+
+    return render_template('acct_reg2.html', form=form)
+
+
 @app.errorhandler(werkzeug.exceptions.Forbidden)
 def handle_forbidden(e):
     return render_template('forbidden.html')
diff --git a/mo/web/templates/acct_reg1.html b/mo/web/templates/acct_reg1.html
new file mode 100644
index 0000000000000000000000000000000000000000..558cac93abb32164cb83b2ebe28b8b6bd9637f74
--- /dev/null
+++ b/mo/web/templates/acct_reg1.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Založení účtu{% endblock %}
+{% block body %}
+
+<p>Nejprve vyplňte svou e-mailovou adresu, která také bude sloužit jako přihlašovací jméno.
+Na ni vám pošleme ověřovací e-mail.
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/acct_reg2.html b/mo/web/templates/acct_reg2.html
new file mode 100644
index 0000000000000000000000000000000000000000..3372d16f9d72f49a5632cbae7cd6bf772298e422
--- /dev/null
+++ b/mo/web/templates/acct_reg2.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Založení účtu{% endblock %}
+{% block body %}
+
+{% if form %}
+
+<p>S údaji o účtu budeme zacházet v souladu se <a href='{{ url_for('doc_gdpr') }}'>zásadami
+zpracování osobních údajů</a>.
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% else %}
+
+<p>Počkejte prosím, až vám přijde e-mail a klikněte na odkaz v něm uvedený.
+
+{% endif %}
+
+{% endblock %}