Authentication is where a web framework's promises get tested, because every entry point of it is a door. A password form, a verification link in a mail, a reset link, a "continue with GitHub" button and a six-digit code from a phone all end in the same sentence - "this session belongs to that user". This guide builds one application with all of those doors on it, from punk new to a passing test suite, using four pieces of the Punk ecosystem: Punk::Auth for the identity and the single-use tokens, Punk::Plugin::Mailer to send the tokens through Resend , Punk::Plugin::TOTP for the second factor, and Punk::Plugin::OAuth2 for GitHub and Google. What you are building Twenty routes, and this is all of them: METHOD PATH TARGET GET /login/totp native handler POST /login/totp native handler GET /auth/:provider sub {...} at OAuth2.pm:56 GET /auth/:provider/callback sub {...} at OAuth2.pm:56 GET / AuthApp::Controller::Web::Root::index GET /register AuthApp::Controller::Web::Auth::register_form POST /register AuthApp::Controller::Web::Auth::register GET /verify/:token AuthApp::Controller::Web::Auth::verify GET /login AuthApp::Controller::Web::Auth::login_form POST /login AuthApp::Controller::Web::Auth::login POST /logout AuthApp::Controller::Web::Auth::logout GET /forgot AuthApp::Controller::Web::Auth::forgot_form POST /forgot AuthApp::Controller::Web::Auth::forgot GET /reset/:token AuthApp::Controller::Web::Auth::reset_form POST /reset/:token AuthApp::Controller::Web::Auth::reset GET /account AuthApp::Controller::Web::Account::home [1 guard] POST /account/totp AuthApp::Controller::Web::Account::enrol [1 guard] POST /account/recovery AuthApp::Controller::Web::Account::recovery [1 guard] GET /vault/secrets AuthApp::Controller::Web::Vault::secrets [1 guard] ANY /static/* static root/static That is punk routes on the finished app. The first four are not yours: the TOTP plugin mounts the challenge, the OAuth2 plugin mounts the provider hop and its callback. The rest is the application, and it is small: sign up and prove the address, sign in, forget and reset a password, enrol a phone, and one page - the vault - that opens only for a session holding both a signed-in user and a second factor passed since sign-in. punk new AuthApp cpanm Punk Punk::TOTP Punk::OAuth2 Punk::Mailer DBD::SQLite punk new AuthApp cd AuthApp punk new writes a running application: an app.psgi that changes to its own directory before loading the class, a config/punk.yml with the views and static mount set and everything else commented, a two-line application class, one controller, a layout and a welcome template, a stylesheet, a test and a README. The class is the whole routing table: package AuthApp ; use strict ; use warnings ; use Punk ; our VERSION = ' 0.01 '; config ' config/punk.yml '; get ' / ' => ' Web::Root#index '; 1 ; punk dev boots it on port 5000 and restarts on change. Two more generator calls give us the models and controllers we are about to fill in - the files they write are skeletons, and every one of them is replaced below: punk generate model User punk generate model AuthToken --table auth_tokens punk generate controller Auth punk generate controller Account punk generate controller Vault Everything Punk does, it does at to_app : routes are resolved, guard chains flattened, 'Web::Auth#login' turned into a coderef, the configuration read and every keyword checked. The consequence for the rest of this guide is that wiring mistakes are boot errors. A typo in a plugin option, a template a mail names that does not exist, a missing environment variable - each stops the process from starting, which is the cheapest moment to find out. The app.psgi the generator wrote is kept. The first BEGIN is the generator's: it changes to the application root so the relative paths in the configuration resolve wherever the server was started from. lib/AuthApp.pm is loaded last, after both. app.psgi : #!/usr/bin/env perl use strict ; use warnings ; use FindBin (); use lib " FindBin ::Bin/lib "; BEGIN { chdir FindBin::Binordie"cannotchdirtoFindBin:: Bin or die " cannot chdir toFindBin ::Bin: ! \n "; } use AuthApp ; AuthApp -> to_app ; Secrets before sessions Everything here rides on the session, and a session needs a secret. Mint one next: export AUTHAPP_SESSION_KEY = ( punk secret ) and reference it from the configuration rather than writing it there. This is the finished config/punk.yml - the oauth and plugins blocks at the bottom belong to sections further down, and are explained there: config/punk.yml : # AuthApp configuration. Safe to commit: secrets are referenced here, # never written here. # # Layered - this file, then punk.PUNK_ENV.yml, then punk.local.yml, each # merged over the last. Put deployment differences in the environment file # and machine-local ones in punk.local.yml, which is gitignored. views : Stencil : template_dir : root/templates wrapper : layout.tmpl static : /static : root/static # The application's canonical origin, declared once. The OAuth2 redirect # URIs and every link in an outbound mail derive from it. Never taken from # a request's Host header, which is attacker-supplied. host : http://localhost:5000 # The database. SQLite, in a file under var/ that lib/AuthApp/Schema.pm # creates at boot - Punk ships no migrations, so the schema lives with the # application. database : dsn : dbi:SQLite:dbname=var/authapp.db # Signed cookie sessions, and single-use CSRF tokens over them. The secret # belongs outside this file, like any other secret: `punk secret` mints # one, and a missing AUTHAPP_SESSION_KEY is a boot error, not a default. # The auth battery is declared in lib/AuthApp.pm, right after this file # is applied. session : secret : { env : AUTHAPP_SESSION_KEY } expires : 7d samesite : Lax csrf : true # OAuth2 registrations, read with secret('oauth.github_id') and so on. The # callback URLs registered with each provider are /auth/github/callback # and /auth/google/callback. oauth : github_id : { env : AUTHAPP_GITHUB_ID } github_secret : { env : AUTHAPP_GITHUB_SECRET } google_id : { env : AUTHAPP_GOOGLE_ID } google_secret : { env : AUTHAPP_GOOGLE_SECRET } plugins : TOTP : issuer : AuthApp # names the account in the authenticator app login_path : /login # an unauthenticated step-up goes here render : totp_page # the challenge page, as a helper of ours recovery_model : AuthToken # recovery codes share the token table { env: NAME } is resolved at boot from outside the file; app->config shows [redacted] in its place, so the configuration can be logged. There is no default syntax, and that is the point: an application that boots with an empty session key because nobody set the variable is an application whose sessions anyone can forge, so it does not boot. csrf: true is the bare form of the keyword, the same as writing csrf; in the class; a mapping there carries its options ( keep , exempt , the field and header names). One keyword does go in the class rather than the file, immediately after the file is applied, because it reads better beside the routes that use it: lib/AuthApp.pm config ' config/punk.yml '; # The authentication battery: who the signed-in user is (User), where # single-use tokens live (AuthToken - verification and reset links, and the # TOTP plugin's recovery codes), and where a guard sends a stranger. auth model => ' User ', token_model => ' AuthToken ', login_path => ' /login '; With csrf on, every POST, PUT, PATCH and DELETE must carry a live token and using one spends it. The token is not injected into your templates - copying a hashref per render to add one key would tax every page for the sake of the ones with forms - so you pass c->csrf_field to the view and print it with {% raw csrf %} . A helper further down does that once for every page. One consequence worth knowing before it surprises you: the default keeps one live token per session, so the same form open in two tabs submits once. csrf keep => 3 relaxes that. A database and two models Punk ships no migrations. The shipped model backend is plain DBI, and the schema is yours to create - here, at boot, so the application runs from a fresh checkout: lib/AuthApp/Schema.pm : package AuthApp:: Schema ; use strict ; use warnings ; use DBI (); use File:: Basename (); # The two tables the application needs, created on demand so it runs from a # fresh checkout with nothing to set up first. Punk ships no migrations; a # real application would keep them somewhere less casual than this. # # `users` is Punk::Auth's schema plus the three columns Punk::Plugin::TOTP # reads and writes. `auth_tokens` is shared: verification and reset links # (kinds `verify` and `reset`) and TOTP recovery codes (kind `totp_recovery`) # are all rows in it, told apart by `kind`. my @DDL = ( << ' SQL ', CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT , email TEXT NOT NULL , password_hash TEXT , -- null: federated - only verified INTEGER NOT NULL DEFAULT 0 , totp_secret TEXT , totp_last_counter INTEGER , totp_enabled INTEGER NOT NULL DEFAULT 0 , created TEXT NOT NULL ) SQL ' CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users (lower(email)) ', << ' SQL ', CREATE TABLE IF NOT EXISTS auth_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT , user_id INTEGER NOT NULL , kind TEXT NOT NULL , digest TEXT NOT NULL , expires INTEGER NOT NULL ) SQL ' CREATE UNIQUE INDEX IF NOT EXISTS auth_tokens_digest ON auth_tokens (digest) ', ); sub ensure { my ( class , dsn)=@;die"AuthApp::Schema:nodatabasedsnconfigured\n"unlessdsn ) = @_ ; die " AuthApp::Schema: no database dsn configured \n " unless dsn ; # dbi:SQLite:dbname=var/authapp.db - make sure var/ exists if ( dsn =~ /dbname=([^;]+)/ ) { my dir = File::Basename:: dirname ( 1);mkdir1 ); mkdirdir unless - d dir ; } my dbh = DBI -> connect ( dsn,undef,undef,RaiseError=>1,AutoCommit=>1,PrintError=>0);dsn , undef , undef , { RaiseError => 1 , AutoCommit => 1 , PrintError => 0 }); dbh -> do ( )for@DDL;_ ) for @DDL ; dbh -> disconnect ; return dsn ; } 1 ; password_hash is nullable on purpose. A user who arrives through GitHub has no password, and Punk::Auth's check_password is simply false for a null hash - it is a meaningful state, not a missing value. The token table holds only SHA-256 digests; the plaintext of a token exists in the link that was mailed and nowhere else. The users row is Punk::Auth's four columns plus three the TOTP plugin owns, and the model declares every one of them, because the DBI backend writes only declared fields: lib/AuthApp/Model/User.pm : package AuthApp::Model:: User ; use Punk:: Model ; table ' users '; # Every column is declared, because the DBI backend writes only declared # fields: a column left out here is a column that can never be written. # Punk::Auth reads email, password_hash and verified; Punk::Plugin::TOTP # reads and writes the three totp_ columns - the counter as a number, # which is what the plugin hands back. field id => { type => ' integer ', primary => 1 }; field email => { type => ' string ', required => 1 }; field password_hash => { type => ' string ' }; field verified => { type => ' integer ' }; field totp_secret => { type => ' string ' }; field totp_last_counter => { type => ' number ' }; field totp_enabled => { type => ' integer ' }; field created => { type => ' string ' }; 1 ; The token model is the same shape over auth_tokens : lib/AuthApp/Model/AuthToken.pm : package AuthApp::Model:: AuthToken ; use Punk:: Model ; table ' auth_tokens '; # Punk::Auth's token table: only the SHA-256 digest of a token is stored, # and a row is deleted the moment it is presented, valid or not. Verification # and reset links live here, and so do the TOTP plugin's recovery codes. field id => { type => ' integer ', primary => 1 }; field user_id => { type => ' integer ' }; field kind => { type => ' string ' }; field digest => { type => ' string ' }; field expires => { type => ' integer ' }; 1 ; Both are discovered automatically: anything under AuthApp::Model:: registers without a model line. Wiring auth The application class, after the keywords above, installs three helpers. The first draws every page through the layout with the same four things - who is signed in, the one-request notice the last action left in the flash, and the CSRF field: helper page => sub { my ( c , template,template , vars , %opt ) = @_ ; my flash=flash = c -> flash || {}; return c>render(c -> render ( template , { user => c>currentuser,notice=>c -> current_user , notice => flash -> { notice }, kind => flash>kind//ok,csrf=>flash -> { kind } // ' ok ', csrf => c -> csrf_field , % { vars || {} }, }, %opt ); }; c->current_user loads the row once per request and memoises it; c->flash reads what the previous request set. The layout prints the nav from user and the notice when there is one, and every form in every template carries {% raw csrf %} . Here is that layout, the welcome page it wraps, and the controller behind the front page: root/templates/layout.tmpl : <!doctype html> <html lang= "en" > <head> <meta charset= "utf-8" > <meta name= "viewport" content= "width=device-width, initial-scale=1" > <title> {% title | default('AuthApp') %} </title> <link rel= "stylesheet" href= "/static/style.css" > </head> <body> <header> <h1><a href= "/" > AuthApp </a></h1> <nav> {% if user %} <span> signed in as <code> {% user.email %} </code></span> <a href= "/account" > Account </a> <a href= "/vault/secrets" > Vault </a> <form method= "post" action= "/logout" > {% raw csrf %} <button> Sign out </button></form> {% else %} <a href= "/login" > Sign in </a> <a href= "/register" > Create an account </a> {% end %} </nav> </header> {% if notice %} <p class= "notice {% kind %}" > {% raw notice %} </p> {% end %} <main> {% content %} </main> </body> </html> root/templates/welcome.tmpl : <h2> Passwords, mail, a second factor and social login </h2> <p> One Punk application with every door on it: create an account and prove the address by mail, sign in with a password or with GitHub or Google, enrol a phone as a second factor, and reach a page that only a session holding both can open. </p> {% if user %} <p> You are signed in. The <a href= "/account" > account page </a> is where the second factor is enrolled; <a href= "/vault/secrets" > the vault </a> is what it protects. </p> {% else %} <p><a href= "/register" > Create an account </a> or <a href= "/login" > sign in </a> . </p> {% end %} lib/AuthApp/Controller/Web/Root.pm : package AuthApp::Controller::Web:: Root ; use strict ; use warnings ; use parent ' Punk::Controller '; our VERSION = ' 0.01 '; sub index { my ( c)=@;returnc ) = @_ ; return c -> page (' welcome '); } 1 ; END

=head1 NAME

AuthApp::Controller::Web::Root - the front page

=cut The stylesheet is the demo's only one - system fonts, light and dark, nothing else: root/static/style.css : /* The demo's one stylesheet: system fonts, light and dark, nothing else. */ :root { color-scheme : light dark } body { font : 16px / 1.5 system-ui , sans-serif ; max-width : 40rem ; margin : 3rem auto ; padding : 0 1rem } header { display : flex ; flex-wrap : wrap ; justify-content : space-between ; align-items : baseline ; gap : 1rem ; margin-bottom : 1.5rem } header h1 { margin : 0 } header h1 a { color : inherit ; text-decoration : none } nav { display : flex ; gap : 1rem ; align-items : center ; font-size : .9rem } nav form { margin : 0 ; display : inline } h2 { margin-top : 2rem } code { user-select : all } form .stack { display : flex ; flex-direction : column ; gap : .75rem ; max-width : 22rem ; margin : 1rem 0 } form .stack label { display : flex ; flex-direction : column ; gap : .25rem } form .row { display : flex ; gap : .5rem ; margin : 1rem 0 ; align-items : center } input { font : inherit ; padding : .5rem .75rem } button , a .button { font : inherit ; padding : .5rem 1rem ; border-radius : 6px ; border : 1px solid currentColor ; background : none ; color : inherit ; text-decoration : none ; cursor : pointer } p .row { display : flex ; gap : .5rem ; flex-wrap : wrap } .qr { width : 16rem ; max-width : 80vw ; margin : 1rem 0 } .qr svg { width : 100% ; height : auto ; display : block ; background : #fff ; border-radius : 8px } ul .status { list-style : none ; padding : 0 } li , .notice { padding : .35rem .6rem ; border-radius : 6px ; margin : .25rem 0 } .ok { background : #1a7f3722 } .bad { background : #b3261e22 } .quiet { opacity : .8 } small { opacity : .6 } The password door is in lib/AuthApp/Controller/Web/Auth.pm : sub login { my ( c)=@;myc ) = @_ ; my email = _clean_email ( c>param(email));myc -> param (' email ')); my pw = c>param(password)//;myc -> param (' password ') // ''; my user = c>model(User)>get(email=>c -> model (' User ') -> get ( email => email ); # check_password burns the same work when there is no user, so the # response time does not say whether the address exists return c -> page (' auth/login ', { email => email , error => ' Wrong email or password. ' }) unless c>checkpassword(c -> check_password ( user , pw);returnpw ); return c -> page (' auth/login ', { email => email , error => ' Verify your address first - the link is in your mail. ' }) unless user -> { verified }; if ( Punk::Auth::Password:: needs_rehash ( user -> { password_hash })) { c -> model (' User ') -> update ({ id => user>id,passwordhash=>Punk::Auth::Password::hash(user -> { id }, password_hash => Punk::Auth::Password:: hash ( pw ) }); } return c>signin(c -> sign_in ( user , to => c>safepath(c -> safe_path ( c -> param (' to '), ' /account ')); } Three things here are the battery's, not ours. check_password takes undef for the user and burns a full PBKDF2 verification before saying no, so the timing of a wrong password and the timing of an unknown address are the same. needs_rehash is the opportunistic cost upgrade: when the hashing parameters are raised in a later release, each user's hash moves to the new cost on their next successful login, with no migration. And safe_path guards the ?to= that auth_guard writes when it redirects a stranger to the login: the value a login form reads back is whatever the browser sent, and someone else may have written that link, so anything that is not a same-origin relative path becomes /account . The form it reads, with the social buttons and the hidden to that auth_guard filled in: root/templates/auth/login.tmpl :

Sign in

{% if error %}

{% error %}

{% end %} {% raw csrf %} Email Password Sign in

Continue with GitHub Continue with Google

Forgotten your password? - No account yet?

The guard itself is one line in the class: # Signed in: the account page, enrolment and recovery codes. my account=under/account=>authguard;account = under ' /account ' => auth_guard ; account -> get (' / ' => ' Web::Account#home '); account -> post (' /totp ' => ' Web::Account#enrol '); account -> post (' /recovery ' => ' Web::Account#recovery '); auth_guard negotiates on Accept : a browser is redirected to login_path with ?to= set, anything else gets a 401 in the house error shape. The common case - is anyone signed in - runs entirely in C, one session load and one hash fetch, and every route under the scope inherits it without a per-action check. Mail through Resend Verification and reset links need a way out of the process. The mailer plugin takes one transport and makes it the application's: # Outbound mail. With RESEND_API_KEY in the environment every message goes # through Resend; without it the capture t