diff --git a/lib/AccessCheck/App.pm b/lib/AccessCheck/App.pm
index 16cfbc1ab2f3f056e3fbdebdcba4e117c7834d75..d3e9538e140a9e04fe6ce7a0bb0aeea41bf084a5 100644
--- a/lib/AccessCheck/App.pm
+++ b/lib/AccessCheck/App.pm
@@ -86,14 +86,14 @@ sub startup {
 
     my $routes = $self->routes();
 
-    $routes->get('/')->to(controller => 'home', action => 'run')->name('home');
-    $routes->get('/status')->to(controller => 'status', action => 'run')->name('status');
-    $routes->get('/step1')->to(controller => 'step1', action => 'run')->name('step1');
-    $routes->get('/step2')->to(controller => 'step2', action => 'run')->name('step2');
-    $routes->get('/send_challenge')->to(controller => 'send_challenge', action => 'run')->name('send_challenge');
-    $routes->get('/validate_challenge')->to(controller => 'validate_challenge', action => 'run')->name('validate_challenge');
-    $routes->get('/step4')->to(controller => 'step4', action => 'run')->name('step4');
-    $routes->get('/step5')->to(controller => 'step5', action => 'run')->name('step5');
+    $routes->get('/')->to(controller => 'controller', action => 'home')->name('home');
+    $routes->get('/status')->to(controller => 'controller', action => 'status')->name('status');
+    $routes->get('/select_entity')->to(controller => 'controller', action => 'select_entity')->name('select_entity');
+    $routes->get('/select_email')->to(controller => 'controller', action => 'select_email')->name('select_email');
+    $routes->get('/send_challenge')->to(controller => 'controller', action => 'send_challenge')->name('send_challenge');
+    $routes->get('/validate_challenge')->to(controller => 'controller', action => 'validate_challenge')->name('validate_challenge');
+    $routes->get('/show_accounts_html')->to(controller => 'controller', action => 'show_accounts_html')->name('show_accounts_html');
+    $routes->get('/show_accounts_csv')->to(controller => 'controller', action => 'show_accounts_csv')->name('show_accounts_csv');
 
 }
 
diff --git a/lib/AccessCheck/App/Controller.pm b/lib/AccessCheck/App/Controller.pm
index 9851bbf5cafe09bbfa75ddd6d1da17c8afc295de..4b03fac613ca2eefa8ff6ee378581a79b2fbf217 100644
--- a/lib/AccessCheck/App/Controller.pm
+++ b/lib/AccessCheck/App/Controller.pm
@@ -5,12 +5,16 @@ use Mojo::Base qw(Mojolicious::Controller);
 use English qw(-no_match_vars);
 use HTTP::AcceptLanguage;
 use Syntax::Keyword::Try;
+use Template::Constants qw(:chomp);
+use UNIVERSAL::require;
 
+use AccessCheck::Data::Account;
 use AccessCheck::Data::DB;
 use AccessCheck::Data::Entity;
 use AccessCheck::Data::Token;
 use AccessCheck::L10N;
 use AccessCheck::Regexp;
+use AccessCheck::Tools;
 
 sub init_l10n {
     my $self = shift;
@@ -228,4 +232,515 @@ sub mock_contacts {
     }
 }
 
+sub home {
+    my $self = shift;
+
+    $self->init_l10n();
+
+    $self->render(status => 200, template => 'home', format => 'html');
+}
+
+=head2 status()
+
+Return the health status of the frontend.
+
+=cut
+
+sub status {
+    my $self  = shift;
+
+    my $config = $self->app()->config();
+
+    if (!$config->{status}) {
+        $self->render(
+            status => 403,
+            text   => "unauthorized access"
+        );
+        return;
+    }
+
+    List::MoreUtils->require();
+    Mojo::Util->require();
+
+    my $client_ip   = $self->forwarded_for();
+    my @allowed_ips = $self->string_to_list($config->{status}->{allowed});
+
+    if (List::MoreUtils::none { Mojo::Util::network_contains($_, $client_ip) } @allowed_ips) {
+        $self->render(
+            status => 403,
+            text   => "unauthorized access"
+        );
+        return;
+    }
+
+    Sys::Hostname->require();
+    my $status = $config->{status}->{disabled} ? 'disabled' : 'available';
+    
+    my $health = {
+        status => $status,
+        host   => hostname(),
+    };
+
+    $self->render(status => 200, json => $health);
+}
+
+sub select_entity {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $sps = AccessCheck::Data::Entity->get_entities(
+        db    => $db,
+        query => [
+            type => 'sp',
+        ],
+        sort_by => 'display_name'
+    );
+
+    my $idp;
+    if ($user) {
+        my $idps = AccessCheck::Data::Entity->get_entities(
+            db    => $db,
+            query => [
+                type     => 'idp',
+                entityid => $user->{idp}
+            ]
+        );
+        $idp = $idps->[0];
+    }
+
+    $self->stash(sps => $sps);
+    $self->stash(idp => $idp);
+
+    $self->render(
+        status   => 200,
+        template => 'select_entity',
+        format   => 'html'
+    );
+}
+
+sub select_email {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $sp = $self->get_sp(entityid => $entityid);
+    return if !$sp;
+
+    # override metadata contacts if needed
+    $self->mock_contacts($sp);
+
+    $self->stash(sp => $sp);
+    $self->stash(entityid => $entityid);
+
+    $self->render(
+        status   => 200,
+        template => 'select_email',
+        format   => 'html'
+    );
+}
+
+sub send_challenge {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    return if !$self->check_csrf_token();
+
+    my $entityid = $self->param('entityid');
+    my $email    = $self->param('email');
+
+    my $sp = $self->get_sp(entityid => $entityid);
+    return if !$sp;
+
+    return $self->abort(
+        log_message  => "Missing parameter: email",
+        user_message => "missing_email"
+    ) if !$email;
+
+    return $self->abort(
+        log_message  => "Invalid parameter: email",
+        user_message => "invalid_email"
+    ) if $email !~ $AccessCheck::Regexp::email;
+
+    # override metadata contacts if needed
+    $self->mock_contacts($sp);
+
+    ## Check that email is a known contact for this SP
+    return $self->abort(
+        log_message  => "Requested a token for SP $entityid with unautorized address $email",
+        user_message => "internal",
+    ) if !$sp->is_contact($email);
+
+    # delete any previous token for the same email/service couple
+    my $old_token = AccessCheck::Data::Token->new(
+        db            => $db,
+        email_address => $email,
+        entityid      => $entityid,
+    );
+
+    if ($old_token->load(speculative => 1)) {
+        try {
+            $old_token->delete();
+        } catch  {
+            return $self->abort(
+                log_message  => "Failed to delete old authentication token",
+                user_message => "internal"
+            );
+        }
+    }
+
+    # compute a new token
+    DateTime->require();
+    my $validity_period =
+        $config->{service}->{tokens_validity_period};
+    my $token = AccessCheck::Data::Token->new(
+        db              => $db,
+        email_address   => $email,
+        entityid        => $entityid,
+        creation_date   => DateTime->now(),
+        expiration_date => DateTime->now()->add(hours => $validity_period),
+        secret          => AccessCheck::Tools::generate_secret(20)
+    );
+
+    try {
+        $token->save();
+    } catch {
+        return $self->abort(
+            log_message  => "Failed to save creation authentication token",
+            user_message => "internal"
+        );
+    }
+
+    # build content
+    my $theme              = $config->{setup}->{templates_theme} || 'default';
+    my $base_templates_dir = $self->app()->home()->child('templates');
+    my $tt2 = Template->new({
+        ENCODING     => 'utf8',
+        PRE_CHOMP    => CHOMP_ONE,
+        INCLUDE_PATH => [
+            $base_templates_dir->child('mail', $theme),
+            $base_templates_dir->child('mail'),
+        ]
+    });
+
+    my $data = {
+        app => {
+            url           => $config->{app}->{url},
+            support_email => $config->{app}->{support_email},
+            version       => $config->{app}->{version},
+            name          => $config->{app}->{name},
+        },
+        user          => $user->{name},
+        source_ip     => $self->forwarded_for(),
+        idp           => { entityid => $user->{idp}, },
+        sp            => { entityid => $entityid, },
+        to            => $email,
+        token         => $token->secret(),
+        challenge_url => $self->url_for('validate_challenge')->query(entityid => $entityid, email => $email)->to_abs(),
+        lh            => $l10n
+    };
+    my $text_content;
+    my $html_content;
+    $tt2->process('send_challenge.txt.tt2',  $data, \$text_content);
+    $tt2->process('send_challenge.html.tt2', $data, \$html_content);
+
+    Email::MIME->require();
+    Email::Sender::Simple->require();
+    my $message = Email::MIME->create(
+        header_str => [
+            'From'         => sprintf('%s <%s>', $config->{app}->{name}, $config->{mailer}->{from}),
+            'To'           => $email,
+            'Subject'      => sprintf('[%s] %s', $config->{app}->{name}, $l10n->maketext("Test accounts request")),
+            'Content-Type' => 'multipart/alternative'
+        ],
+        parts => [
+            Email::MIME->create(
+                attributes => {
+                    content_type => "text/plain",
+                    charset      => 'utf-8',
+                    encoding     => 'quoted-printable'
+                },
+                body_str => $text_content
+            ),
+            Email::MIME->create(
+                attributes => {
+                    content_type => "text/html",
+                    charset      => 'utf-8',
+                    encoding     => 'quoted-printable'
+                },
+                body_str => $html_content
+            ),
+        ]
+    );
+
+    try {
+        local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
+        Email::Sender::Simple->send($message);
+    } catch($error) {
+        return $self->abort(
+            log_message  => "Mail notification error: $error",
+            user_message => "mail_notification_failure"
+        );
+    }
+
+    $log->info(
+        sprintf(
+            "Token %s send to %s for entity %s",
+            $token->secret(),
+            $email,
+            $entityid,
+        )
+    );
+
+    $self->redirect_to('validate_challenge', email => $email, entityid => $entityid);
+}
+
+sub validate_challenge {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $email    = $self->param('email');
+
+    my $sp = $self->get_sp(entityid => $entityid);
+    return if !$sp;
+
+    return $self->abort(
+        log_message  => "Missing parameter: email",
+        user_message => "missing_email"
+    ) if !$email;
+
+    return $self->abort(
+        log_message  => "Invalid parameter: email",
+        user_message => "invalid_email"
+    ) if $email !~ $AccessCheck::Regexp::email;
+
+    my $base_templates_dir = $self->app()->home()->child('templates');
+    my $profiles = $base_templates_dir
+        ->child('accounts')
+        ->list()
+        ->map(sub { m/([^\/]+).tt2$/})
+        ->to_array();
+
+    $self->stash(entityid => $entityid);
+    $self->stash(email    => $email);
+    $self->stash(validity => $config->{service}->{account_validity_period});
+    $self->stash(profiles => $profiles);
+
+    $self->render(
+        status   => 200,
+        template => 'validate_challenge',
+        format   => 'html'
+    );
+}
+
+sub display_accounts_html {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $email    = $self->param('email');
+    my $token    = $self->param('token');
+    my $validity = $self->param('validity');
+    my $profiles = $self->every_param('profiles');
+
+    my $sp = $self->get_sp(entityid => $entityid);
+    return if !$sp;
+
+    return if !$self->check_token(token => $token, entityid => $entityid);
+
+    ## create test accounts
+    my @accounts;
+
+    DateTime->require();
+    my $creation_date  = DateTime->now();
+    my $token_expiration_date = DateTime->now()->add(
+        hours => $config->{service}->{tokens_validity_period}
+    );
+    my $account_expiration_date = DateTime->now()->add(
+        days => $validity
+    );
+
+    my $download_token = AccessCheck::Data::Token->new(
+        db              => $db,
+        email_address   => $email,
+        entityid        => $entityid,
+        creation_date   => $creation_date,
+        expiration_date => $token_expiration_date,
+        secret          => AccessCheck::Tools::generate_secret(20)
+    );
+
+    try {
+        $download_token->save();
+    } catch {
+        return $self->abort(
+            log_message  => "Failed to save download authentication token",
+            user_message => "internal"
+        );
+    }
+
+    my $key = AccessCheck::Tools::generate_secret(10);
+
+    foreach my $profile (@$profiles) {
+        my $password = AccessCheck::Tools::generate_password(10);
+        my $account = AccessCheck::Data::Account->new(
+            db              => $db,
+            profile         => $profile,
+            entityid        => $entityid,
+            scope           => $config->{idp}->{scope},
+            password        => $password,
+            password_crypt  => AccessCheck::Tools::encrypt($password, $key),
+            password_hash   => AccessCheck::Tools::hash($password),
+            token           => $download_token->secret(),
+            creation_date   => $creation_date,
+            expiration_date => $account_expiration_date,
+        );
+        next unless $account->save();
+        push @accounts, $account;
+    }
+
+    return $self->abort(
+        log_message  => "Failed to create test accounts for SP $entityid",
+        user_message => "accounts_creation_failure"
+    ) if !@accounts;
+
+    ## Update simpleSAMLphp configuration to enable test accounts
+    my $accounts = AccessCheck::Data::Account->get_accounts(db => $db);
+
+    try {
+        AccessCheck::Tools::update_ssp_authsources(
+            $self->app()->home()->child('templates'),
+            $config->{setup}->{accounts_file},
+            $accounts
+        );
+    } catch($error) {
+        return $self->abort(
+            log_message  => "Failed to create simpleSAMLphp configuration file: $error",
+            user_message => "accounts_creation_failure"
+        );
+    }
+
+    $log->info(sprintf("Token validated for entityid=%s", $entityid));
+
+    $self->stash(accounts => \@accounts);
+    $self->stash(idp      => { name => $config->{idp}->{name} });
+    $self->stash(sp       => { entityid => $entityid, url => $sp->information_url() });
+    $self->stash(email    => $email);
+    $self->stash(days     => $validity);
+    $self->stash(
+        download_url => $self->url_for('show_accounts_csv')->query(
+            entityid => $entityid,
+            token    => $download_token->secret(),
+            key      => $key
+        )
+    );
+
+    $self->render(
+        status   => 200,
+        template => 'show_accounts',
+        format   => 'html'
+    );
+}
+
+sub display_accounts_csv {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    my $l10n = $self->init_l10n();
+    my $user = $self->init_user();
+    my $db   = $self->init_db();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $token    = $self->param('token');
+    my $key      = $self->param('key');
+
+    return if !$self->check_token(token => $token, entityid => $entityid);
+
+    # load accounts from database
+    my $accounts = AccessCheck::Data::Account->get_accounts(
+        db    => $db,
+        query => [
+            token => $token
+        ],
+    );
+
+    foreach my $account (@$accounts) {
+        my $password = AccessCheck::Tools::decrypt(
+            $account->password_crypt(), $key
+        );
+        $account->password($password);
+    }
+
+    $app->types()->type(csv => 'text/csv');
+
+    $self->stash(accounts => $accounts);
+
+    $self->render(
+        status   => 200,
+        template => 'accounts',
+        format   => 'csv'
+    );
+}
+
 1;
diff --git a/lib/AccessCheck/App/Home.pm b/lib/AccessCheck/App/Home.pm
deleted file mode 100644
index 0ef09af0cc954ceb6daea0afbcee8cad5b33045e..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Home.pm
+++ /dev/null
@@ -1,24 +0,0 @@
-package AccessCheck::App::Home;
-
-=head1 NAME
-
-AccessCheck::App::Home - Home page controller
-
-=head1 DESCRIPTION
-
-=cut
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use English qw(-no_match_vars);
-use Syntax::Keyword::Try;
-
-sub run {
-    my $self = shift;
-
-    $self->init_l10n();
-
-    $self->render(status => 200, template => 'home', format => 'html');
-}
-
-1;
diff --git a/lib/AccessCheck/App/Status.pm b/lib/AccessCheck/App/Status.pm
deleted file mode 100644
index b4bea276ba7fc6a1e3653e9fb35e1e1168b45aa0..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Status.pm
+++ /dev/null
@@ -1,65 +0,0 @@
-package AccessCheck::App::Status;
-
-=head1 NAME
-
-AccessCheck::App::Status - Health monitoring controller
-
-=head1 DESCRIPTION
-
-Health monitoring page
-
-Access: restricted by IP address
-
-=cut
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use English qw(-no_match_vars);
-use List::MoreUtils qw(none);
-use Mojo::Util qw(network_contains);
-use Sys::Hostname;
-use Syntax::Keyword::Try;
-
-=head1 INSTANCE METHODS
-
-=head2 run()
-
-Return the health status of the frontend.
-
-=cut
-
-sub run {
-    my $self  = shift;
-
-    my $config = $self->app()->config();
-
-    if (!$config->{status}) {
-        $self->render(
-            status => 403,
-            text   => "unauthorized access"
-        );
-        return;
-    }
-
-    my $client_ip   = $self->forwarded_for();
-    my @allowed_ips = $self->string_to_list($config->{status}->{allowed});
-
-    if (none { network_contains($_, $client_ip) } @allowed_ips) {
-        $self->render(
-            status => 403,
-            text   => "unauthorized access"
-        );
-        return;
-    }
-
-    my $status = $config->{status}->{disabled} ? 'disabled' : 'available';
-    
-    my $health = {
-        status => $status,
-        host   => hostname(),
-    };
-
-    $self->render(status => 200, json => $health);
-}
-
-1;
diff --git a/lib/AccessCheck/App/Step1.pm b/lib/AccessCheck/App/Step1.pm
deleted file mode 100644
index 4750fa380699b1ad7e9b508872dca9b5a8bf33bb..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Step1.pm
+++ /dev/null
@@ -1,55 +0,0 @@
-package AccessCheck::App::Step1;
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use English qw(-no_match_vars);
-use Syntax::Keyword::Try;
-
-use AccessCheck::Data::Entity;
-
-sub run {
-    my $self = shift;
-
-    my $app    = $self->app();
-    my $config = $app->config();
-    my $log    = $app->log();
-
-    my $l10n = $self->init_l10n();
-    my $user = $self->init_user();
-    my $db   = $self->init_db();
-
-    if ($config->{app}->{login_url}) {
-        return if !$self->check_authentication();
-    }
-
-    my $sps = AccessCheck::Data::Entity->get_entities(
-        db    => $db,
-        query => [
-            type => 'sp',
-        ],
-        sort_by => 'display_name'
-    );
-
-    my $idp;
-    if ($user) {
-        my $idps = AccessCheck::Data::Entity->get_entities(
-            db    => $db,
-            query => [
-                type     => 'idp',
-                entityid => $user->{idp}
-            ]
-        );
-        $idp = $idps->[0];
-    }
-
-    $self->stash(sps => $sps);
-    $self->stash(idp => $idp);
-
-    $self->render(
-        status   => 200,
-        template => 'step1',
-        format   => 'html'
-    );
-}
-
-1;
diff --git a/lib/AccessCheck/App/Step2.pm b/lib/AccessCheck/App/Step2.pm
deleted file mode 100644
index 4a81b52962d29351340de2b58c22d64aeee2f292..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Step2.pm
+++ /dev/null
@@ -1,40 +0,0 @@
-package AccessCheck::App::Step2;
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use English qw(-no_match_vars);
-use Syntax::Keyword::Try;
-
-sub run {
-    my $self = shift;
-
-    my $app    = $self->app();
-    my $config = $app->config();
-    my $log    = $app->log();
-
-    my $l10n = $self->init_l10n();
-    my $user = $self->init_user();
-    my $db   = $self->init_db();
-
-    if ($config->{app}->{login_url}) {
-        return if !$self->check_authentication();
-    }
-
-    my $entityid = $self->param('entityid');
-    my $sp = $self->get_sp(entityid => $entityid);
-    return if !$sp;
-
-    # override metadata contacts if needed
-    $self->mock_contacts($sp);
-
-    $self->stash(sp => $sp);
-    $self->stash(entityid => $entityid);
-
-    $self->render(
-        status   => 200,
-        template => 'step2',
-        format   => 'html'
-    );
-}
-
-1;
diff --git a/lib/AccessCheck/App/Step4.pm b/lib/AccessCheck/App/Step4.pm
deleted file mode 100644
index f3b68a1bd06f3f26bbde8f2a53c4a3f743a74d9e..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Step4.pm
+++ /dev/null
@@ -1,134 +0,0 @@
-package AccessCheck::App::Step4;
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use DateTime;
-use Email::MIME;
-use Email::Sender::Simple;
-use English qw(-no_match_vars);
-use Syntax::Keyword::Try;
-use Template::Constants qw(:chomp);
-
-use AccessCheck::Data::Account;
-use AccessCheck::Data::Token;
-use AccessCheck::Tools;
-
-sub run {
-    my $self = shift;
-
-    my $app    = $self->app();
-    my $config = $app->config();
-    my $log    = $app->log();
-
-    my $l10n = $self->init_l10n();
-    my $user = $self->init_user();
-    my $db   = $self->init_db();
-
-    if ($config->{app}->{login_url}) {
-        return if !$self->check_authentication();
-    }
-
-    my $entityid = $self->param('entityid');
-    my $email    = $self->param('email');
-    my $token    = $self->param('token');
-    my $validity = $self->param('validity');
-    my $profiles = $self->every_param('profiles');
-
-    my $sp = $self->get_sp(entityid => $entityid);
-    return if !$sp;
-
-    return if !$self->check_token(token => $token, entityid => $entityid);
-
-    ## create test accounts
-    my @accounts;
-
-    my $creation_date  = DateTime->now();
-    my $token_expiration_date = DateTime->now()->add(
-        hours => $config->{service}->{tokens_validity_period}
-    );
-    my $account_expiration_date = DateTime->now()->add(
-        days => $validity
-    );
-
-    my $download_token = AccessCheck::Data::Token->new(
-        db              => $db,
-        email_address   => $email,
-        entityid        => $entityid,
-        creation_date   => $creation_date,
-        expiration_date => $token_expiration_date,
-        secret          => AccessCheck::Tools::generate_secret(20)
-    );
-
-    try {
-        $download_token->save();
-    } catch {
-        return $self->abort(
-            log_message  => "Failed to save download authentication token",
-            user_message => "internal"
-        );
-    }
-
-    my $key = AccessCheck::Tools::generate_secret(10);
-
-    foreach my $profile (@$profiles) {
-        my $password = AccessCheck::Tools::generate_password(10);
-        my $account = AccessCheck::Data::Account->new(
-            db              => $db,
-            profile         => $profile,
-            entityid        => $entityid,
-            scope           => $config->{idp}->{scope},
-            password        => $password,
-            password_crypt  => AccessCheck::Tools::encrypt($password, $key),
-            password_hash   => AccessCheck::Tools::hash($password),
-            token           => $download_token->secret(),
-            creation_date   => $creation_date,
-            expiration_date => $account_expiration_date,
-        );
-        next unless $account->save();
-        push @accounts, $account;
-    }
-
-    return $self->abort(
-        log_message  => "Failed to create test accounts for SP $entityid",
-        user_message => "accounts_creation_failure"
-    ) if !@accounts;
-
-    ## Update simpleSAMLphp configuration to enable test accounts
-    my $accounts = AccessCheck::Data::Account->get_accounts(db => $db);
-
-    try {
-        AccessCheck::Tools::update_ssp_authsources(
-            $self->app()->home()->child('templates'),
-            $config->{setup}->{accounts_file},
-            $accounts
-        );
-    } catch($error) {
-        return $self->abort(
-            log_message  => "Failed to create simpleSAMLphp configuration file: $error",
-            user_message => "accounts_creation_failure"
-        );
-    }
-
-    $log->info(sprintf("Token validated for entityid=%s", $entityid));
-
-    $self->stash(accounts => \@accounts);
-    $self->stash(idp      => { name => $config->{idp}->{name} });
-    $self->stash(sp       => { entityid => $entityid, url => $sp->information_url() });
-    $self->stash(email    => $email);
-    $self->stash(days     => $validity);
-    $self->stash(
-        download_url => $self->url_for('step5')->query(
-            entityid => $entityid,
-            token    => $download_token->secret(),
-            key      => $key
-        )
-    );
-
-    $self->render(
-        status   => 200,
-        template => 'step4',
-        format   => 'html'
-    );
-}
-
-1;
diff --git a/lib/AccessCheck/App/Step5.pm b/lib/AccessCheck/App/Step5.pm
deleted file mode 100644
index 9618c9f308d7627259687ab97025adbb13182b6b..0000000000000000000000000000000000000000
--- a/lib/AccessCheck/App/Step5.pm
+++ /dev/null
@@ -1,62 +0,0 @@
-package AccessCheck::App::Step5;
-
-use Mojo::Base qw(AccessCheck::App::Controller);
-
-use DateTime;
-use Email::MIME;
-use Email::Sender::Simple;
-use English qw(-no_match_vars);
-use Syntax::Keyword::Try;
-use Template::Constants qw(:chomp);
-
-use AccessCheck::Data::Account;
-use AccessCheck::Tools;
-
-sub run {
-    my $self = shift;
-
-    my $app    = $self->app();
-    my $config = $app->config();
-    my $log    = $app->log();
-
-    my $l10n = $self->init_l10n();
-    my $user = $self->init_user();
-    my $db   = $self->init_db();
-
-    if ($config->{app}->{login_url}) {
-        return if !$self->check_authentication();
-    }
-
-    my $entityid = $self->param('entityid');
-    my $token    = $self->param('token');
-    my $key      = $self->param('key');
-
-    return if !$self->check_token(token => $token, entityid => $entityid);
-
-    # load accounts from database
-    my $accounts = AccessCheck::Data::Account->get_accounts(
-        db    => $db,
-        query => [
-            token => $token
-        ],
-    );
-
-    foreach my $account (@$accounts) {
-        my $password = AccessCheck::Tools::decrypt(
-            $account->password_crypt(), $key
-        );
-        $account->password($password);
-    }
-
-    $app->types()->type(csv => 'text/csv');
-
-    $self->stash(accounts => $accounts);
-
-    $self->render(
-        status   => 200,
-        template => 'accounts',
-        format   => 'csv'
-    );
-}
-
-1;
diff --git a/lib/Makefile.am b/lib/Makefile.am
index 9c90300f04a636df23fd0e13ec5dec780e82d4d6..4e08d6a074acd65da2d30535521963d5b2551cec 100644
--- a/lib/Makefile.am
+++ b/lib/Makefile.am
@@ -13,15 +13,7 @@ nobase_applib_DATA = \
 	AccessCheck/L10N/en.pm \
 	AccessCheck/L10N/fr.pm \
 	AccessCheck/App.pm \
-	AccessCheck/App/Home.pm \
 	AccessCheck/App/Controller.pm \
-	AccessCheck/App/Status.pm \
-	AccessCheck/App/Step1.pm \
-	AccessCheck/App/Step2.pm \
-	AccessCheck/App/SendChallenge.pm \
-	AccessCheck/App/ValidateChallenge.pm \
-	AccessCheck/App/Step4.pm \
-	AccessCheck/App/Step5.pm \
 	AccessCheck/Template/Plugin/Quote.pm
 
 EXTRA_DIST = $(nobase_applib_DATA)
diff --git a/t/app.t b/t/app.t
index 01fc8c197f19d44dc1361d67740e214c3f45bb2c..13c840df70d703947220bc218cda2be5b8cebc1d 100755
--- a/t/app.t
+++ b/t/app.t
@@ -170,7 +170,7 @@ named_subtest "index page" => sub {
     $t->get_ok('/')
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check')
-      ->text_is('a[href=/step1]'  => 'Get started', 'get started button');
+      ->text_is('a[href=/select_entity]'  => 'Get started', 'get started button');
 
     my $res = $t->tx()->res();
     html_ok($res) or diag_file($res, $test_dir);
@@ -179,7 +179,7 @@ named_subtest "index page" => sub {
 named_subtest "SP selection page" => sub {
     my $t = get_test_object(test => $_[0]);
 
-    $t->get_ok('/step1')
+    $t->get_ok('/select_entity')
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check', 'expected title')
       ->element_exists('select[id=all][name=all]', 'SP selection widget');
@@ -191,7 +191,7 @@ named_subtest "SP selection page" => sub {
 named_subtest "email selection page, missing entityid" => sub {
     my $t = get_test_object(test => $_[0]);
 
-    $t->get_ok('/step2')
+    $t->get_ok('/select_email')
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check', 'expected title')
       ->content_like(qr/Error:[\n\s]+missing parameter 'entityid'/, 'expected error message');
@@ -203,7 +203,7 @@ named_subtest "email selection page, missing entityid" => sub {
 named_subtest "email selection page, invalid entityid" => sub {
     my $t = get_test_object(test => $_[0]);
 
-    $t->get_ok('/step2' => form => {entityid => 'foo'})
+    $t->get_ok('/select_email' => form => {entityid => 'foo'})
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check', 'expected title')
       ->content_like(qr/Error:[\n\s]+invalid parameter 'entityid'/, 'expected error message');
@@ -215,7 +215,7 @@ named_subtest "email selection page, invalid entityid" => sub {
 named_subtest "email selection page, valid entityid" => sub {
     my $t = get_test_object(test => $_[0]);
 
-    $t->get_ok('/step2' => form => {entityid => 'https://sp.renater.fr/'})
+    $t->get_ok('/select_email' => form => {entityid => 'https://sp.renater.fr/'})
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check', 'expected title')
       ->element_exists('input[name=email][value=contact1@renater.fr]', 'email selection widget');
@@ -362,7 +362,7 @@ named_subtest "index page, french version" => sub {
     $t->get_ok('/')
       ->status_is(200)
       ->text_is('html head title' => 'eduGAIN Access Check')
-      ->text_is('html body a[href=/step1]'  => 'Commencer', 'get started button');
+      ->text_is('html body a[href=/select_entity]'  => 'Commencer', 'get started button');
 
     my $res = $t->tx()->res();
     html_ok($res) or diag_file($res, $test_dir);
diff --git a/templates/Makefile.am b/templates/Makefile.am
index 95f86b9d11ec5ea7ac250a01f06352bf5f2fb05e..cc20499bf1c1ecf1db14f36d2c811e25058bd926 100644
--- a/templates/Makefile.am
+++ b/templates/Makefile.am
@@ -19,17 +19,17 @@ nobase_apptemplates_DATA = \
 	web/edugain/errors.html.tt2 \
 	web/edugain/home.html.tt2 \
 	web/edugain/index.html.tt2 \
-	web/edugain/step1.html.tt2 \
-	web/edugain/step2.html.tt2 \
+	web/edugain/select_entity.html.tt2 \
+	web/edugain/select_email.html.tt2 \
 	web/edugain/validate_challenge.html.tt2 \
-	web/edugain/step4.html.tt2 \
+	web/edugain/show_accounts.html.tt2 \
 	web/renater/errors.html.tt2 \
 	web/renater/home.html.tt2 \
 	web/renater/index.html.tt2 \
-	web/renater/step1.html.tt2 \
-	web/renater/step2.html.tt2 \
+	web/renater/select_entity.html.tt2 \
+	web/renater/select_email.html.tt2 \
 	web/renater/validate_challenge.html.tt2 \
-	web/renater/step4.html.tt2
+	web/renater/show_accounts.html.tt2
 
 EXTRA_DIST = $(nobase_apptemplates_DATA)
 
diff --git a/templates/web/edugain/home.html.tt2 b/templates/web/edugain/home.html.tt2
index fc64d08c11404102d6b3b06184d5d6f7f4ed3593..6141b0bd51fb587e693e5216f1ce34bbd8bc9dae 100644
--- a/templates/web/edugain/home.html.tt2
+++ b/templates/web/edugain/home.html.tt2
@@ -9,6 +9,6 @@
 
 <h2>[% c.loc("Get started") %]</h2>
 <p>[% c.loc("To start testing your own services, start by selecting one your are administrator for.") %]</p>
-<p class="text-center"><a href="[% IF app.login_url %][% app.login_url %]?target=[% c.url_for('step1') %][% ELSE %][% c.url_for('step1') %][% END %]" class="button">[% c.loc("Get started") %]</a></p>
+<p class="text-center"><a href="[% IF app.login_url %][% app.login_url %]?target=[% c.url_for('select_entity') %][% ELSE %][% c.url_for('select_entity') %][% END %]" class="button">[% c.loc("Get started") %]</a></p>
 
 [% END %]
diff --git a/templates/web/edugain/step2.html.tt2 b/templates/web/edugain/select_email.html.tt2
similarity index 97%
rename from templates/web/edugain/step2.html.tt2
rename to templates/web/edugain/select_email.html.tt2
index c544c15158530eea36a7ff6a2534cc5d1fbfb27f..303efc9237cbc3bddbe8387aa7044a3cd78491c4 100644
--- a/templates/web/edugain/step2.html.tt2
+++ b/templates/web/edugain/select_email.html.tt2
@@ -35,7 +35,7 @@
     </div>
 
     <div class="actions clearfix">
-	<button type="submit" class="button" formaction="[% c.url_for('step1') %]" formnovalidate>[% c.loc("Previous") %]</button>
+	<button type="submit" class="button" formaction="[% c.url_for('select_entity') %]" formnovalidate>[% c.loc("Previous") %]</button>
         <button type="submit" class="button">[% c.loc("Next") %]</button>
     </div>
 </form>
diff --git a/templates/web/edugain/step1.html.tt2 b/templates/web/edugain/select_entity.html.tt2
similarity index 98%
rename from templates/web/edugain/step1.html.tt2
rename to templates/web/edugain/select_entity.html.tt2
index a1f6b705bf8608c3dc044aaad3257605b855f8cf..9d8a463efcd4e52ef29b35376238780624beb648 100644
--- a/templates/web/edugain/step1.html.tt2
+++ b/templates/web/edugain/select_entity.html.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER index.html.tt2 %]
-<form class="wizard clearfix" action="[% c.url_for('step2') %]" method="get">
+<form class="wizard clearfix" action="[% c.url_for('select_email') %]" method="get">
     <div class="steps clearfix">
         <ol>
             <li class="current">[% c.loc("Select your service provider") %]</li>
diff --git a/templates/web/edugain/step4.html.tt2 b/templates/web/edugain/show_accounts.html.tt2
similarity index 96%
rename from templates/web/edugain/step4.html.tt2
rename to templates/web/edugain/show_accounts.html.tt2
index 1e6b54f719f19760f5ef2a946b3a41debb97151e..817b062db0a41f447cbe3c8e22a99433783ed39f 100644
--- a/templates/web/edugain/step4.html.tt2
+++ b/templates/web/edugain/show_accounts.html.tt2
@@ -79,5 +79,5 @@
     </div>
 </div>
 
-<p class="text-center"><a href="[% c.url_for('step1') %]" class="button">[% c.loc("Test another service") %]</a></p>
+<p class="text-center"><a href="[% c.url_for('select_entity') %]" class="button">[% c.loc("Test another service") %]</a></p>
 [% END %]
diff --git a/templates/web/edugain/validate_challenge.html.tt2 b/templates/web/edugain/validate_challenge.html.tt2
index 1e9388414867e348262fa4afc80713a054b7fae8..df7203626dfebb27e59374c39b4bf4a79c89b098 100644
--- a/templates/web/edugain/validate_challenge.html.tt2
+++ b/templates/web/edugain/validate_challenge.html.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER index.html.tt2 %]
-<form class="wizard clearfix" action="[% c.url_for('step4') %]" method="get">
+<form class="wizard clearfix" action="[% c.url_for('show_accounts_html') %]" method="get">
     <div class="steps clearfix">
         <ol>
             <li class="done">[% c.loc("Select your service provider") %]</li>
@@ -42,7 +42,7 @@
     </div>
 
     <div class="actions clearfix">
-        <button type="submit" class="button" formaction="[% c.url_for('step2') %]" formnovalidate>[% c.loc("Previous") %]</button>
+        <button type="submit" class="button" formaction="[% c.url_for('select_email') %]" formnovalidate>[% c.loc("Previous") %]</button>
         <button type="submit" class="button">[% c.loc("Next") %]</button>
     </div>
 </form>
diff --git a/templates/web/renater/home.html.tt2 b/templates/web/renater/home.html.tt2
index 819da9c63de4f330a003730c5bab17fc4b1e3ffa..86b8cd139a81acd9a8d671bb2a87389d77d4a4f4 100644
--- a/templates/web/renater/home.html.tt2
+++ b/templates/web/renater/home.html.tt2
@@ -9,6 +9,6 @@
 
 <h2>[% c.loc("Get started") %]</h2>
 <p>[% c.loc("To start testing your own services, start by selecting one your are administrator for.") %]</p>
-<p class="text-center"><a href="[% IF app.login_url %][% app.login_url %]?target=[% c.url_for('step1') %][% ELSE %][% c.url_for('step1') %][% END %]" class="button">[% c.loc("Get started") %]</a></p>
+<p class="text-center"><a href="[% IF app.login_url %][% app.login_url %]?target=[% c.url_for('select_entity') %][% ELSE %][% c.url_for('select_entity') %][% END %]" class="button">[% c.loc("Get started") %]</a></p>
 
 [% END %]
diff --git a/templates/web/renater/step2.html.tt2 b/templates/web/renater/select_email.html.tt2
similarity index 100%
rename from templates/web/renater/step2.html.tt2
rename to templates/web/renater/select_email.html.tt2
diff --git a/templates/web/renater/step1.html.tt2 b/templates/web/renater/select_entity.html.tt2
similarity index 99%
rename from templates/web/renater/step1.html.tt2
rename to templates/web/renater/select_entity.html.tt2
index dc46ca310c611c2d0ec0c9b6460315a348e4452e..f405a8a440675b0e69826a27d8cbc26f6049fb39 100644
--- a/templates/web/renater/step1.html.tt2
+++ b/templates/web/renater/select_entity.html.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER index.html.tt2 %]
-<form class="wizard clearfix" action="[% c.url_for('step2') %]" method="get">
+<form class="wizard clearfix" action="[% c.url_for('select_email') %]" method="get">
     <div class="steps clearfix">
         <ol>
             <li class="current">[% c.loc("Select your service provider") %]</li>
diff --git a/templates/web/renater/step4.html.tt2 b/templates/web/renater/show_accounts.html.tt2
similarity index 96%
rename from templates/web/renater/step4.html.tt2
rename to templates/web/renater/show_accounts.html.tt2
index 9e5d2bdd5d20184ed6507a7e7d277ce52bfa0746..0e63867e3e942557d11de1353a75d03929414298 100644
--- a/templates/web/renater/step4.html.tt2
+++ b/templates/web/renater/show_accounts.html.tt2
@@ -81,5 +81,5 @@
     </div>
 </div>
 
-<p class="text-center"><a href="[% c.url_for('step1') %]" class="button">[% c.loc("Test another service") %]</a></p>
+<p class="text-center"><a href="[% c.url_for('select_entity') %]" class="button">[% c.loc("Test another service") %]</a></p>
 [% END %]
diff --git a/templates/web/renater/validate_challenge.html.tt2 b/templates/web/renater/validate_challenge.html.tt2
index bb83a121405c8fa841a1414ec799f26f34a30b80..2c922f3ca0416bdcf67c14702357ffa86409c400 100644
--- a/templates/web/renater/validate_challenge.html.tt2
+++ b/templates/web/renater/validate_challenge.html.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER index.html.tt2 %]
-<form class="wizard clearfix" action="[% c.url_for('step4') %]" method="get">
+<form class="wizard clearfix" action="[% c.url_for('show_accounts_html') %]" method="get">
     <div class="steps clearfix">
         <ol>
             <li class="done">[% c.loc("Select your service provider") %]</li>