Skip to content

Django Forms and Authentication and Permissions

Django Forms are the safest and cleanest way to handle user input.

They solve three problems at once:

  • parsing raw input
  • validating data on the server
  • rendering secure HTML with clear errors

Validation first

Form fields enforce data types and rules before data reaches your database.

Cleaner views

Views stay focused on request flow while forms own parsing and validation.

Security built in

Forms work naturally with CSRF and safe output rendering.

Reusable logic

Validation rules live in one place and can be reused across views.

Always follow this pattern:

  1. GET: render an unbound form.
  2. POST: bind form with request.POST (and request.FILES if needed).
  3. Run form.is_valid().
  4. If valid, use cleaned_data and redirect.
  5. If invalid, render the same template with errors.
flowchart TD A[User opens page] --> B[GET request] B --> C[Create unbound form] C --> D[Render template] D --> E[User submits form] E --> F[POST request] F --> G[Create bound form with request.POST] G --> H{form.is_valid?} H -->|No| I[Render same template with errors] H -->|Yes| J[Read cleaned_data] J --> K[Save data or run business logic] K --> L[Redirect success page]
  • Unbound form: created with no submitted data (usually GET).
  • Bound form: created with submitted data (usually POST).
from django.shortcuts import redirect, render
from .forms import ContactForm
def contact_view(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
# process form.cleaned_data
return redirect("contact-success")
else:
form = ContactForm()
return render(request, "contact.html", {"form": form})

Use forms.Form when input is not a direct one-to-one model mapping.

from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=120)
email = forms.EmailField()
subject = forms.CharField(max_length=200)
message = forms.CharField(widget=forms.Textarea)
subscribe = forms.BooleanField(required=False)

Important field options:

  • required
  • initial
  • label
  • help_text
  • validators
  • widget
name = forms.CharField(
max_length=120,
required=True,
label="Full name",
help_text="Use first and last name",
)

form.is_valid() runs this pipeline:

  1. Convert raw input to Python values.
  2. Run built-in validators.
  3. Run clean_<field>() methods.
  4. Run clean() for cross-field validation.
  5. Populate cleaned_data.

Raw request.POST values are strings. cleaned_data gives parsed Python values.

from django import forms
from django.core.exceptions import ValidationError
class ContactForm(forms.Form):
name = forms.CharField(max_length=120)
email = forms.EmailField()
def clean_name(self):
value = self.cleaned_data["name"].strip()
if len(value.split()) < 2:
raise ValidationError("Please enter first and last name.")
return value
def clean_email(self):
value = self.cleaned_data["email"].lower()
if value.endswith("@temporarymail.com"):
raise ValidationError("Temporary email addresses are not allowed.")
return value

Rules:

  • method name must match clean_<fieldname> exactly
  • always return cleaned value
  • raise ValidationError when invalid
from django import forms
class BookingForm(forms.Form):
start_date = forms.DateField()
end_date = forms.DateField()
guest_count = forms.IntegerField(min_value=1)
def clean(self):
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
end_date = cleaned_data.get("end_date")
if start_date and end_date and end_date < start_date:
self.add_error("end_date", "End date must be after start date.")
return cleaned_data

Use raise ValidationError(...) in clean() when the error is form-wide (shows in non_field_errors).

Always include CSRF token in POST forms.

<form method="post" novalidate>
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send</button>
</form>

novalidate is useful while learning because browser validation UI is disabled and Django errors are easier to inspect.

{{ form.as_p }} wraps each field in a paragraph.

<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="form-errors">
{{ form.non_field_errors }}
</div>
{% endif %}
<div>
{{ form.name.label_tag }}
{{ form.name }}
{% if form.name.help_text %}
<small>{{ form.name.help_text }}</small>
{% endif %}
{{ form.name.errors }}
</div>
<div>
{{ form.email.label_tag }}
{{ form.email }}
{{ form.email.errors }}
</div>
<button type="submit">Submit</button>
</form>

What each part means:

  • form.field.label_tag: HTML label linked to the input
  • form.field: field widget HTML
  • form.field.errors: field-specific error list
  • form.non_field_errors: form-wide errors from clean()
  • form.field.help_text: optional helper message

Use ModelForm when form fields map directly to a model.

# models.py
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
published = models.BooleanField(default=False)
def __str__(self):
return self.title
# forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "body", "published"]
labels = {
"title": "Article title",
}
help_texts = {
"title": "Keep the title clear and specific.",
}
widgets = {
"body": forms.Textarea(attrs={"rows": 6}),
}
def clean_title(self):
title = self.cleaned_data["title"].strip()
if len(title) < 10:
raise ValidationError("Title must be at least 10 characters.")
return title
form = ArticleForm(request.POST)
if form.is_valid():
article = form.save() # create or update
if form.is_valid():
article = form.save(commit=False)
article.author = request.user
article.save()

Use commit=False when you must set extra fields not exposed in the form.

# views.py
from django.shortcuts import get_object_or_404, redirect, render
from .forms import ArticleForm
from .models import Article
def article_create(request):
if request.method == "POST":
form = ArticleForm(request.POST)
if form.is_valid():
form.save()
return redirect("article-list")
else:
form = ArticleForm()
return render(request, "articles/form.html", {"form": form, "mode": "create"})
def article_update(request, pk):
article = get_object_or_404(Article, pk=pk)
if request.method == "POST":
form = ArticleForm(request.POST, instance=article)
if form.is_valid():
form.save()
return redirect("article-detail", pk=article.pk)
else:
form = ArticleForm(instance=article)
return render(request, "articles/form.html", {"form": form, "mode": "update"})
<!-- templates/articles/form.html -->
<h1>{% if mode == "create" %}Create Article{% else %}Update Article{% endif %}</h1>
<form method="post" novalidate>
{% csrf_token %}
{{ form.non_field_errors }}
<div>
{{ form.title.label_tag }}
{{ form.title }}
{{ form.title.errors }}
</div>
<div>
{{ form.body.label_tag }}
{{ form.body }}
{{ form.body.errors }}
</div>
<div>
{{ form.published }}
{{ form.published.label_tag }}
{{ form.published.errors }}
</div>
<button type="submit">{% if mode == "create" %}Create{% else %}Update{% endif %}</button>
</form>

For uploads, pass request.FILES and set enctype="multipart/form-data".

class DocumentForm(forms.Form):
title = forms.CharField(max_length=120)
file = forms.FileField()
def upload_document(request):
if request.method == "POST":
form = DocumentForm(request.POST, request.FILES)
if form.is_valid():
uploaded_file = form.cleaned_data["file"]
# save file
return redirect("upload-success")
else:
form = DocumentForm()
return render(request, "upload.html", {"form": form})
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Upload</button>
</form>

This section shows a clean and practical auth setup for real projects.

It covers login, signup, logout, settings, page protection, and role-based protection.

flowchart TD A[User opens signup or login page] --> B[Submit form] B --> C[Server validates data] C --> D{Valid data?} D -->|No| E[Show form errors] D -->|Yes| F[Create account or login user] F --> G[Create session] G --> H[Redirect to dashboard] H --> I[User opens protected page] I --> J{Logged in?} J -->|No| K[Redirect to login] J -->|Yes| L[Allow access]
# project/settings.py
INSTALLED_APPS = [
# ...
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
# Required only when you define a custom user model
AUTH_USER_MODEL = "appname.User"
# Where to send user when login is required
LOGIN_URL = "login"
# Where to send user after successful login
LOGIN_REDIRECT_URL = "dashboard"
# Where to send user after logout
LOGOUT_REDIRECT_URL = "login"
# Production-safe cookies (turn on when HTTPS is enabled)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True

For most projects, Django’s default user model is enough. It includes username, email, password, and more.

from django.contrib.auth.models import User

If you need a custom user model, inherit from AbstractUser and set AUTH_USER_MODEL in settings before your first migration.

from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
# Add custom fields here
pass

When writing queries in forms/views, prefer get_user_model() so your code works with both default and custom user models.

Keep auth form logic in forms.py so views stay clean and easy to read.

# forms.py
from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
User = get_user_model()
class LoginForm(forms.Form):
username = forms.CharField(max_length=150)
password = forms.CharField(widget=forms.PasswordInput)
class SignupForm(forms.Form):
username = forms.CharField(max_length=150)
email = forms.EmailField()
password1 = forms.CharField(widget=forms.PasswordInput)
password2 = forms.CharField(widget=forms.PasswordInput)
def clean_username(self):
username = self.cleaned_data["username"].strip()
if User.objects.filter(username=username).exists():
raise ValidationError("Username already exists.")
return username
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if User.objects.filter(email=email).exists():
raise ValidationError("Email already exists.")
return email
def clean(self):
cleaned_data = super().clean()
password1 = cleaned_data.get("password1")
password2 = cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise ValidationError("Passwords do not match.")
return cleaned_data

Use LoginForm to parse and validate data, then call authenticate().

# views.py
from django.contrib.auth import authenticate, login
from django.shortcuts import redirect, render
from .forms import LoginForm
def login_view(request):
if request.method == "POST":
form = LoginForm(request.POST)
if form.is_valid():
username = form.cleaned_data["username"]
password = form.cleaned_data["password"]
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect("dashboard")
form.add_error(None, "Invalid username or password.")
else:
form = LoginForm()
return render(request, "auth/login.html", {"form": form})

Use SignupForm and create_user() so validation and password handling stay safe.

# views.py
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.shortcuts import redirect, render
from .forms import SignupForm
User = get_user_model()
def signup_view(request):
if request.method == "POST":
form = SignupForm(request.POST)
if form.is_valid():
user = User.objects.create_user(
username=form.cleaned_data["username"],
email=form.cleaned_data["email"],
password=form.cleaned_data["password1"],
)
login(request, user)
messages.success(request, "Account created successfully.")
return redirect("dashboard")
else:
form = SignupForm()
return render(request, "auth/signup.html", {"form": form})

logout() clears the current session and logs user out.

# views.py
from django.contrib.auth import logout
from django.shortcuts import redirect
def logout_view(request):
logout(request)
return redirect("login")
# app/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("login/", views.login_view, name="login"),
path("signup/", views.signup_view, name="signup"),
path("logout/", views.logout_view, name="logout"),
path("dashboard/", views.dashboard_view, name="dashboard"),
]

You can use built-in decorators or create custom ones to protect views. These decorators check if the user is authenticated and has the right permissions before allowing access to the view.

Use @login_required for any page that should not be public.

# views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@login_required
def dashboard_view(request):
return render(request, "dashboard.html")

Use this when only admin users should open a view.

# decorators.py
from functools import wraps
from django.http import HttpResponseForbidden
def admin_required(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
from django.shortcuts import redirect
return redirect("login")
if not request.user.is_staff:
return HttpResponseForbidden("You do not have permission to open this page.")
return view_func(request, *args, **kwargs)
return _wrapped_view
# views.py
from django.shortcuts import render
from .decorators import admin_required
@admin_required
def admin_reports_view(request):
return render(request, "admin/reports.html")

Groups and permissions are the core of Django’s authorization system. They allow you to control who can do what in your application. It helps you implement role-based access control (RBAC) in a clean and scalable way.

Think like this:

  • Permission: one specific ability (for example, view_order)
  • Group: a bundle of permissions (for example, Customer Service)
  • User: can get permissions from groups and/or direct assignment
  • To know default permissions, check auth_permission table in the database after running migrations. (You have to see the table name auth_permission in the database, not the model name Permission.)

Groups help to bundle permissions into roles. Instead of assigning permissions to each user, you assign them to groups and then add users to those groups. This is much easier to manage as your user base grows. You can also have users in multiple groups, which gives you flexibility in permission management.

Permissions are the specific actions that users can perform. They are defined in the model’s Meta class and can be either default (add, change, delete, view) or custom (like cancel_order). Permissions are stored in the database and linked to models via content types.

You can individually assign permissions to users, but it’s usually better to assign them to groups and then add users to those groups. This way, you can manage permissions at the group level, which is more efficient and easier to maintain.

When you create a model and run migrations, Django automatically creates default permissions for that model:

  • add
  • change
  • delete
  • view

Example for a Customer model:

  • add_customer
  • change_customer
  • delete_customer
  • view_customer

These are stored in the database (auth_permission) and linked to models via Django content types.

Use this flow in admin:

  1. Open Admin -> Authentication and Authorization -> Groups.
  2. Click Add group.
  3. Enter a clear role name, for example Customer Service.
  4. Select required permissions (for example customer and order permissions).
  5. Save.

Then assign users to that group:

  1. Open Admin -> Users.
  2. Select a user.
  3. Set Staff status if the user needs admin access.
  4. Add the user to one or more groups.
  5. Save.

Default permissions are not always enough.

Example: “cancel order” is a business action, not a basic CRUD action.

Define custom permissions in model Meta:

# models.py
from django.db import models
class Order(models.Model):
# fields...
class Meta:
permissions = [
("cancel_order", "Can cancel order"),
]

Then run migrations:

Terminal window
python manage.py makemigrations
python manage.py migrate

After migration, these permissions appear in admin and can be assigned to groups/users.

Use built-in decorators for simple checks.

from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import render
@login_required
@permission_required("store.view_order", raise_exception=True)
def orders_list(request):
return render(request, "orders/list.html")

Use raise_exception=True when you want 403 Forbidden instead of redirect.