django-allauth, or Django's own auth plus a few hand-rolled views?¶
django-allauth. Not because the hand-rolled path is long — Django already ships password reset end to end — but because the two things Django does not ship are exactly the two this app needs: email verification (no view, no model, no token that survives being used) and per-endpoint rate limiting on a public front door. Both are load-bearing for a signup form open to the internet, and both are where a hand-rolled version quietly gets it wrong.
The pattern worry is smaller than it looks. allauth owns the entrance — signup, login,
verify, reset — which are full-page navigations, not <dialog> sheets over a live list. The
204 + SSE rule exists for Item mutations and is untouched by any of it.
The manual approval gate has a first-class seam: AccountAdapter.get_login_stages(), which
lets an ApprovalStage run after email verification. It is real and supported in the source
but not documented in prose — that caveat is spelled out below, with the documented
fallback.
Issue #21, part of
#18. Date: 2026-09-22.
django-allauth 65.19.4 (current; released 2026-09-17). Django 6.0.5 as actually locked in
uv.lock — note that CLAUDE.md and pyproject.toml still say 5.1.
Sources (all primary)¶
- [S1] django-allauth 65.19.4 wheel, downloaded from PyPI and read on disk. All allauth file/line references below are from that wheel, not from a docs page or a blog.
- [S2] django-allauth PyPI JSON metadata — https://pypi.org/pypi/django-allauth/json.
- [S3] django-allauth release notes — https://docs.allauth.org/en/latest/release-notes/recent.html.
- [S4] django-allauth docs: quickstart, account configuration, account adapter, rate limits, advanced usage — https://docs.allauth.org/en/latest/.
- [S5] Django 6.0.5 as installed in this repo's
.venv, read on disk:django/contrib/auth/{urls,views,forms,backends,tokens}.py. - [S6] This repo at
main—shared_calendar/settings.py,accounts/,uv.lock.
What is actually current¶
| Latest django-allauth | 65.19.4, released 2026-09-17 [S2][S3] |
| Python supported | >=3.10, classifiers through 3.14 [S2] |
| Django supported | 4.2, 5.0, 5.1, 5.2, 6.0, 6.1 [S2] |
| Django 6.1 support added in | 65.19.0 [S3] |
| This repo's locked Django | 6.0.5 [S6] |
So the version question has no tension in it: allauth supports the Django this app actually runs, and the project is shipping roughly weekly [S3]. It is not a stale dependency.
Worth flagging separately: pyproject.toml says django>=5.1 and CLAUDE.md says "Django
5.1", but uv.lock pins django==6.0.5 [S6]. That is a docs drift unrelated to this ticket,
but any auth work will be written against 6.0.
What Django already gives you for free¶
Read from the installed Django, not from memory [S5]:
- Password reset, complete.
PasswordResetView,PasswordResetDoneView,PasswordResetConfirmView,PasswordResetCompleteView, wired indjango/contrib/auth/urls.py, withPasswordResetFormthat renders and sends the mail itself. Templates ship too (registration/password_reset_*.html, in the admin app's template directory). - A one-shot, timeout-bounded token.
PasswordResetTokenGenerator._make_hash_valuereturnsf"{user.pk}{user.password}{login_timestamp}{timestamp}{email}"[S5]. Because the hash folds in the password hash andlast_login, the token self-invalidates once it is used. - Password change,
AUTH_PASSWORD_VALIDATORS, and theis_activecheck inModelBackend.user_can_authenticate/AuthenticationForm.confirm_login_allowed[S5].
So "password reset" is not part of the hand-rolled cost. It is already done.
What Django does not give you, at all¶
- No signup view.
UserCreationFormexists as a form (forms.py:254); there is no view and no URL for it anywhere incontrib/auth/urls.py[S5]. You write the view, the URL, the template, and the "email already taken" policy. - No email verification. Nothing: no model recording that an address was proved, no
confirmation view, no resend, no cooldown. The reset token generator is not a substitute —
its hash is keyed on password and
last_login, neither of which changes when someone verifies an address, so a verification link built on it stays valid after use untilPASSWORD_RESET_TIMEOUTexpires it. Making it one-shot means subclassing_make_hash_valueto fold in your ownverifiedflag. That is a small amount of code in a place where being subtly wrong is a security bug. - No rate limiting. Nothing in
contrib.auththrottles login attempts, signup, or reset requests. Map #18 already lists "bot pressure on a public signup form" as unspecified; this is where that lands. - No email-as-login.
ACCOUNT_LOGIN_METHODSis an allauth setting [S4]; in plain Django you write a backend or a custom user model. Map #18 has already settled that email is the login. - No
EmailAddressconcept, so no "an address belongs to one account, proven", which is what makes an invite flow safe later.
What allauth gives, and what it imposes¶
Gives¶
EmailAddress(user,email,verified,primary) andEmailConfirmation/EmailConfirmationHMAC[S1,allauth/account/models.py].ACCOUNT_EMAIL_VERIFICATION—"none" | "optional" | "mandatory", default"optional"[S1,app_settings.py:97]. Mandatory means no login until the address is proved.- Verification by link or by 6-character code (
ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED, defaultFalse) [S4]. Codes are the better fit for a phone-first PWA: no cross-app link handoff, no "opened in the wrong browser". - Password reset, change, set, and reset-by-code.
ACCOUNT_LOGIN_METHODS(email) andACCOUNT_SIGNUP_FIELDS[S4].- Rate limiting out of the box, per endpoint [S1,
app_settings.py:268-300]:login 30/m/ip,login_failed 10/m/ip,5/300s/key,signup 20/m/ip,reset_password 20/m/ip,5/m/key,confirm_email 1/180s/key,change_password 5/m/user,manage_email 10/m/user. Backed bydjango.core.cache[S1,core/internal/ratelimit.py:17], with a cache lock so concurrent requests cannot slip past (a fix that landed in 65.19.3 [S3]). ACCOUNT_PREVENT_ENUMERATION, defaultTrue[S4] — signup and reset do not leak whether an address is already registered. That matters more than usual here, where the whole user base is people you know.- 15 signals including
user_signed_upandemail_confirmed[S1,account/signals.py] — the natural place to notify you that a signup is waiting for approval (#26).
Imposes¶
| Imposition | Actual size, measured |
|---|---|
| Migrations | 9, all on one app, one new table plus indexes [S1, account/migrations/] |
| Templates | 34 page templates + 28 email templates ship with defaults [S1]. Overriding is just dropping a file at templates/account/<name>.html — this repo's TEMPLATES["DIRS"] filesystem loader is listed before app_directories [S6], so project files win. Realistically you restyle ~6: login, signup, password_reset, password_reset_from_key, verification_sent, account_inactive. |
| URL structure | path("accounts/", include("allauth.urls")) [S4]. The app already mounts accounts/ with login/ and logout/ at the same names Django uses, so the collision is with this repo's own three routes, not with anything structural. settings/ stays where it is. |
| Settings surface | ~73 ACCOUNT_* settings exist [S1, app_settings.py]; a working install needs about 8. |
| Middleware | allauth.account.middleware.AccountMiddleware is required [S4]. It is async-aware [S1, account/middleware.py:23], which matters — this app is ASGI under uvicorn. |
| Auth backend | allauth.account.auth_backends.AuthenticationBackend [S4]. |
django.contrib.sites |
Not required. SITES_ENABLED is computed as apps.is_installed("django.contrib.sites") [S1, allauth/app_settings.py:23] and the quickstart does not list it [S4]. This is the most commonly-repeated stale claim about allauth; it is no longer true. |
| Session engine | Must not be signed_cookies [S4]. This app uses the default. |
| Invitations | Not supported and not planned — "Invitation handling is not supported, and most likely will not be any time soon" [S4, advanced usage]. It offers is_open_for_signup() and stash_verified_email() as integration points. The invite half of map #18 is yours to write either way. |
Does it fight htmx?¶
Mostly the question dissolves, and where it doesn't, there is a seam.
It dissolves for the entrance flows. Signup, login, verify and reset are page
transitions for a logged-out person — there is no live list on screen to refresh, so the
204 + HX-Trigger + SSE pattern (ADR-0002) has nothing to apply to. allauth's views are plain
FormView/TemplateView subclasses [S1, account/views.py] that render a template on GET
and on invalid POST, and redirect on success. That is the same shape as this repo's existing
CalendarLoginView, which is a bare LoginView subclass with a template [S6]. The current
login.html is a hand-written form posting username/password [S6]; it becomes a
hand-written form posting login/password against an allauth template name. Nothing about
the styling or the show-password toggle changes.
Where it could bite: the in-app flows. account_email (manage addresses) and
password/change/ live behind the login, on the Settings page, where a <dialog> sheet would
be the house idiom. These would be full-page routes rather than sheets. Two exits: override
the template to render sheet markup, or subclass the view and route your own URL, keeping
ACCOUNT_FORMS for the form. Neither is a fight; both are ordinary Django.
One genuine mismatch, worth knowing up front: allauth has no htmx awareness at all — a
grep for HX- across the entire package returns nothing [S1]. So it will never emit
HX-Trigger or return 204 on its own. Every response is a full page or a redirect. That is
fine for the entrance and a small chore for the two in-app flows.
The manual approval gate¶
This is the part the ticket asked to press on, and the answer is better than expected — with one honesty caveat.
The trap that catches both options¶
The obvious approval flag is is_active=False. Do not use it, because in both stacks it
silently breaks password reset:
- Django's
PasswordResetForm.get_users()filtersis_active=True[S5,contrib/auth/forms.py:422-433]. - allauth does the same:
filter_users_by_email(email, is_active=True, ...)[S1,account/forms.py:621and:754].
A person awaiting approval who asks for a reset gets silence, in either design. So the
approval state wants its own field, not is_active. That conclusion is independent of the
stack choice and should be carried straight into #26.
allauth's documented hook¶
AccountAdapter.pre_login() runs before anything else in the login flow and can abort it by
returning a response [S1, account/adapter.py]:
def pre_login(self, request, user, *, email_verification, signal_kwargs,
email, signup, redirect_url) -> HttpResponse | None:
if not user.is_active:
return self.respond_user_inactive(request, user)
return None
def respond_user_inactive(self, request, user) -> HttpResponse:
return headed_redirect_response("account_inactive")
There is a real URL (account/inactive/ → AccountInactiveView) and a real template
(account/account_inactive.html) behind that redirect [S1, account/urls.py,
account/views.py:850]. So "a holding page for someone who is not allowed in yet" is a
first-class, already-routed concept — one of #26's open questions is already half-answered by
the framework.
The ordering problem, and the better seam¶
pre_login runs before the login stages:
def perform_login(request, login):
response = adapter.pre_login(request, login.user, **hook_kwargs)
if response:
return response
return resume_login(request, login) # ← stages run here
[S1, account/internal/flows/login.py:90-97]
Email verification is one of those stages (EmailVerificationStage, [S1,
account/stages.py:142]). So a gate written in pre_login fires before verification is
enforced — which inverts map #18's settled order, where verification proves the address and
approval then decides on the person.
The right seam is one level down. The stage list is itself an adapter method:
def get_login_stages(self) -> list[str]:
ret = []
ret.append("allauth.account.stages.LoginByCodeStage")
ret.append("allauth.account.stages.PhoneVerificationStage")
ret.append("allauth.account.stages.EmailVerificationStage")
...
return ret
[S1, account/adapter.py:859-873]
Override it, append an ApprovalStage after EmailVerificationStage, and the ordering is
exactly what #18 settled: verify first, then approve. LoginStage is a documented-shape base
class with handle() / is_resumable() and the controller walks the list in order [S1,
account/stages.py:23-138].
The caveat, stated plainly: get_login_stages, pre_login and respond_user_inactive
are all in the shipped adapter and are all designed to be overridden, but the prose docs'
adapter page lists neither pre_login nor get_login_stages — it documents
is_open_for_signup, save_user, new_user, the redirect getters and so on [S4]. So the
custom-stage approach is supported by construction but not promised in writing, and could
in principle move between majors. Mitigation is cheap and should be an explicit instruction to
26's implementer: **one test that asserts a verified-but-unapproved person cannot reach the¶
app**, which turns any future breakage into a red test rather than an open front door.
If even that is too much exposure, the documented fallback is pre_login plus a
verification_required ordering handled by keeping ACCOUNT_EMAIL_VERIFICATION = "mandatory"
and letting the approval check tolerate an unverified user — less elegant, entirely public API.
Hand-rolled, for comparison¶
Subclass AuthenticationForm.confirm_login_allowed() [S5, contrib/auth/forms.py:359], which
is explicitly documented as "a policy setting, independent of end-user authentication", and
raise a ValidationError for an unapproved person. That is genuinely clean and genuinely
public API — the one place the hand-rolled option is better. But it only gates the login
form; every other entry point (a verification link that logs you in, a reset-completion that
logs you in) needs the same check repeated by hand, whereas allauth's stage controller sits on
the single funnel all of those pass through.
The hand-rolled cost, concretely¶
Everything Django does not ship, that this app needs:
- Signup view, URL, form (email-as-login, uniqueness, enumeration policy), template.
- An email-verification token that is actually one-shot — a
PasswordResetTokenGeneratorsubclass with averifiedflag in_make_hash_value. - A place to record verification. Either a field on
User(and then a custom user model, or a profile table) or a smallEmailAddress-alike. - Verification send / confirm / resend views, with a resend cooldown.
- Verification email templates, subject and body.
- An email-login auth backend, or a custom user model with
USERNAME_FIELD = "email". - Rate limiting on signup, login, reset and resend — from nothing.
- Enumeration-safe responses on signup and reset.
- The approval gate (cheap either way).
- Tests for all of it.
Call it 400-600 lines plus templates, of which items 2, 7 and 8 are the security-sensitive ones. Against that, allauth is a dependency, nine migrations, six restyled templates, and about eight settings lines.
The house rule that every write goes through a service module survives either way, and is
worth restating for #26: allauth owns account writes (signup, verification, reset), which
are not Item writes. The approval decision — the thing that is this app's own policy, not
the framework's — is exactly what belongs in accounts/services.py, with the adapter stage
calling into it. That keeps the seam where the codebase already puts seams.
Recommendation¶
Adopt django-allauth 65.19.4, with:
ACCOUNT_LOGIN_METHODS = {"email"},ACCOUNT_EMAIL_VERIFICATION = "mandatory",ACCOUNT_SIGNUP_FIELDSwithoutusername.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True— codes beat links on a phone-first PWA.- A custom
AccountAdapteroverridingget_login_stages()to append anApprovalStageafterEmailVerificationStage, with the stage calling a service inaccounts/services.py. - Approval stored in its own field, never
is_active, because both stacks filter reset onis_active. AUTH_PASSWORD_VALIDATORSturned on at the same time (map #18 already settled this; it is currently[]).
Because:
- The two things Django lacks are the two things a public front door needs. Verification and rate limiting are not conveniences here — they are what makes "signup is public" safe. allauth ships both, tested, with a cache-locked limiter that was hardened this month.
- The pattern conflict is smaller than it appears. The entrance is page navigation, not sheets over a live list; ADR-0002 is untouched. Only two in-app flows want sheet treatment, and both are ordinary view subclasses.
- The approval gate has a real seam, ordered correctly after verification, and a real holding-page route already built. The hand-rolled equivalent is cleaner at one door and worse at the others.
- The imposition is measurable and small — 9 migrations, 6 templates, 8 settings, no
contrib.sites, async-safe middleware. - Enumeration protection is on by default, which a hand-rolled version would almost certainly get wrong at least once.
Against, honestly: it is a large dependency for a two-person app; the stage hook is undocumented in prose; and invitations — half of map #18 — are explicitly out of its scope and will be hand-written regardless.
What this implies for #26¶
- Approval is a field of its own, not
is_active. Otherwise password reset breaks for precisely the people waiting to be let in. - Verification then approval is not just the preferred order — it is the order the stage
controller enforces once
ApprovalStageis appended afterEmailVerificationStage. A verified-but-unapproved person and an unverified person are already distinguishable, byEmailAddress.verifiedand by the approval field, at no extra cost. - The holding page exists:
account/inactive/withaccount_inactive.html. #26's "what does an unapproved person see on login" can adopt it rather than invent one. - You learn a signup is waiting via the
user_signed_uporemail_confirmedsignal — preferemail_confirmed, so the queue only contains people whose address is proved. - Approval happens in
/admin/at first: a boolean on the user is one admin list-editable checkbox, and the admin is already mounted. - Rejection wants to be a state, not a deletion, because allauth's
EmailAddressuniqueness is what stops a rejected address signing up again the next minute — deleting the row hands the address back. - The invited partner bypasses approval, not verification. allauth's
stash_verified_email()is the documented hook for skipping verification on an invite link, but map #18 only exempts them from approval, so the invitee should still verify; the invite flow sets the approval field at creation. CONTEXT.mdwill need a word for a person. allauth calls the address an Email address and the proof verified; borrowing that vocabulary keeps the glossary and the code agreeing.