Skip to content

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 in django/contrib/auth/urls.py, with PasswordResetForm that 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_value returns f"{user.pk}{user.password}{login_timestamp}{timestamp}{email}" [S5]. Because the hash folds in the password hash and last_login, the token self-invalidates once it is used.
  • Password change, AUTH_PASSWORD_VALIDATORS, and the is_active check in ModelBackend.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. UserCreationForm exists as a form (forms.py:254); there is no view and no URL for it anywhere in contrib/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 until PASSWORD_RESET_TIMEOUT expires it. Making it one-shot means subclassing _make_hash_value to fold in your own verified flag. That is a small amount of code in a place where being subtly wrong is a security bug.
  • No rate limiting. Nothing in contrib.auth throttles 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_METHODS is 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 EmailAddress concept, 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) and EmailConfirmation / 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, default False) [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) and ACCOUNT_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 by django.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, default True [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_up and email_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() filters is_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:621 and :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:

  1. Signup view, URL, form (email-as-login, uniqueness, enumeration policy), template.
  2. An email-verification token that is actually one-shot — a PasswordResetTokenGenerator subclass with a verified flag in _make_hash_value.
  3. A place to record verification. Either a field on User (and then a custom user model, or a profile table) or a small EmailAddress-alike.
  4. Verification send / confirm / resend views, with a resend cooldown.
  5. Verification email templates, subject and body.
  6. An email-login auth backend, or a custom user model with USERNAME_FIELD = "email".
  7. Rate limiting on signup, login, reset and resend — from nothing.
  8. Enumeration-safe responses on signup and reset.
  9. The approval gate (cheap either way).
  10. 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_FIELDS without username.
  • ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True — codes beat links on a phone-first PWA.
  • A custom AccountAdapter overriding get_login_stages() to append an ApprovalStage after EmailVerificationStage, with the stage calling a service in accounts/services.py.
  • Approval stored in its own field, never is_active, because both stacks filter reset on is_active.
  • AUTH_PASSWORD_VALIDATORS turned on at the same time (map #18 already settled this; it is currently []).

Because:

  1. 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.
  2. 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.
  3. 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.
  4. The imposition is measurable and small — 9 migrations, 6 templates, 8 settings, no contrib.sites, async-safe middleware.
  5. 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 ApprovalStage is appended after EmailVerificationStage. A verified-but-unapproved person and an unverified person are already distinguishable, by EmailAddress.verified and by the approval field, at no extra cost.
  • The holding page exists: account/inactive/ with account_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_up or email_confirmed signal — prefer email_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 EmailAddress uniqueness 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.md will 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.