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 the same time:

  • reading raw input
  • checking (validating) data on the server
  • showing safe HTML with clear error messages

Validation first

Form fields check data types and rules before the data reaches your database.

Cleaner views

Views stay focused on handling the request, while forms take care of parsing and validation.

Security built in

Forms work naturally with CSRF protection and safe output rendering.

Reusable logic

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

Always follow this pattern:

  1. GET: show an empty (unbound) form.
  2. POST: fill the form with request.POST (and request.FILES if needed).
  3. Run form.is_valid().
  4. If valid, use cleaned_data and redirect.
  5. If not valid, show the same template again with the 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 happens on GET).
  • Bound form: created with submitted data (usually happens on 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 the input does not match a model directly, field by field.

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 through these steps:

  1. Turn the raw input into Python values.
  2. Run the built-in validators.
  3. Run any clean_<field>() methods.
  4. Run clean() for checks that involve more than one field.
  5. Fill in cleaned_data.

Raw request.POST values are just strings. cleaned_data gives you the parsed, proper Python values instead.

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:

  • the method name must exactly match clean_<fieldname>
  • always return the cleaned value
  • raise ValidationError when something is 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(...) inside clean() when the error applies to the whole form, not just one field (this shows up in non_field_errors).

Always include the CSRF token inside POST forms.

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

novalidate is useful while you are learning, because it turns off the browser’s own validation pop-ups, which makes it easier to see Django’s own error messages.

{{ form.as_p }} wraps each field inside 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: the HTML label connected to the input
  • form.field: the field’s input HTML
  • form.field.errors: error list for that specific field
  • form.non_field_errors: whole-form errors coming from clean()
  • form.field.help_text: an optional helper message

Use ModelForm when the 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 need to set extra fields that are not part of the form itself.

# 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 file 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 simple and practical way to set up authentication for real projects.

It covers login, signup, logout, settings, protecting pages, and protecting pages based on user roles.

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 good enough. It already 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 your settings before your first migration.

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

When writing queries inside forms or views, prefer using get_user_model() so your code works correctly with both the default user model and a custom one.

Keep your auth form logic inside forms.py so your 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 read and check the 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 together with create_user() so that validation and password handling both 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 the 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 Django’s built-in decorators, or create your own, to protect views. These decorators check if the user is logged in and has the right permissions before letting them access the view.

Use @login_required for any page that should not be open to everyone.

# 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 be able to 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 let you control who is allowed to do what in your app. They help you set up role-based access control (RBAC) in a clean and scalable way.

Think of it 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 be given permissions directly
  • To see the default permissions, check the auth_permission table in the database after running migrations. (You need to look for the table name auth_permission in the database, not the model name Permission.)

Groups help you bundle permissions together into roles. Instead of giving permissions to each user one by one, you give them to a group, and then add users to that group. This becomes much easier to manage as your number of users grows. A user can also belong to more than one group, which gives you a lot of flexibility when managing permissions.

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

You can assign permissions to individual users, but it is usually better to assign them to groups and then add users to those groups instead. 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 get stored in the database (auth_permission) and are linked to models through Django’s content types.

Use this flow in admin:

  1. Open Admin -> Authentication and Authorization -> Groups.
  2. Click Add group.
  3. Type in a clear role name, for example Customer Service.
  4. Select the permissions it needs (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 that user needs admin access.
  4. Add the user to one or more groups.
  5. Save.

The default permissions are not always enough.

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

You can define custom permissions inside the model’s Meta class:

# 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 the migration, these permissions will show up in admin and can be assigned to groups or users.

Use Django’s 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 a 403 Forbidden error instead of a redirect.