If you've built a Django API and you're wondering how to add authentication so that each user can only access their own data, you're in the right place.
Most Django tutorials teach you session-based authentication. That works fine when your frontend and backend live on the same server. But the moment you separate them – say, a React app on Netlify talking to a Django API on PythonAnywhere – then sessions start to break down.
Cookies don't travel well across different domains, and suddenly your login system stops working.
That's where JSON Web Tokens (JWT) come in. JWTs give you a stateless, cookie-free way to authenticate users. They work seamlessly across domains, devices, and platforms. The server doesn't need to remember anything. It just verifies the token's signature and knows exactly who's making the request.
But authentication is only half the problem. Once you know who a user is, you still need to control what they can see. This is where scoping comes in.
Scoping means ensuring that each user can only access their own data. User A should never be able to read, edit, or delete User B's data (notes in our case), even if they somehow guess the right ID.
In this tutorial, you'll build a a personal note-taking API where users can register, log in with JWT tokens, and store notes that only they can access.
Along the way, you'll implement a custom user model, configure SimpleJWT for token-based authentication, and write scoped views that lock each user's data behind their own credentials.
What We'll Cover:
Here's what this tutorial covers:
How to set up a custom user model (and why you should always do this)
How to configure SimpleJWT for access and refresh token authentication
How to build serializers that protect sensitive fields
How to scope your API views so users only see their own data
How to test the entire flow using Postman
Let's get started
Prerequisities
Before you begin, make sure you're comfortable with the following:
Django fundamentals: You should understand how Django projects and apps work, including models, views, URLs, and migrations.
Django REST Framework basics: You should be familiar with serializers, viewsets or API views, and how DRF handles requests and responses.
Basic command line usage: You'll run commands in your terminal throughout this tutorial.
Tools you'll need installed:
Python 3.8 or higher
pip (Python's package manager)
A code editor like Visual Studio Code
Postman (or any API testing tool) for testing your endpoints. You'll use this to send requests to your API.
What is JWT and Why Use It Over Session Authentication?
Before you write any code, it's important to understand what problem JWTs solve and why Django's built-in session authentication isn't always enough.
How Session Authentication Work
Django ships with a session-based authentication system. Here's how it works at a high level:
A user sends their username and password to the server.
The server verifies the credentials and creates a session which is a small record stored in the server's database that says "this user is logged in."
The server sends back a session ID as a cookie. The browser stores this cookie automatically.
On every subsequent request, the browser sends the cookie back to the server. The server looks up the session ID in its database and says "ah, this is User A. Let them through."
This works perfectly when your frontend and backend live on the same domain. The browser handles cookies automatically, and Django manages sessions in the database without you thinking about it.
But this approach has some limitations.
The cross-domain problem: If your React frontend lives at app.example.com and your Django API lives at api.example.com, cookies become tricky. Browsers enforce strict rules about which domains can send and receive cookies.
You can work around this with CORS (Cross-Origin Resource Sharing) headers and special cookie settings, but it adds complexity and can be fragile.
The scalability problem: Every active session is stored in the server's database. If you have 10,000 users logged in at the same time, that's 10,000 session records the server has to look up on every single request. As your application grows, this lookup becomes a bottleneck.
The mobile problem: Mobile apps don't handle cookies the same way browsers do. If you're building an API that will serve both a web app and a mobile app, session cookies create extra headaches.
How JWT Authentication Works
JWTs take a fundamentally different approach. Instead of storing session data on the server, they put the authentication information directly into the token itself.
Here's how the flow works:
A user sends their username and password to the server.
The server verifies the credentials and creates a JWT – a long encoded string that contains information like the user's ID and when the token expires.
The server sends this token back to the client. The client stores it (usually in memory or local storage).
On every subsequent request, the client includes the token in the request header. The server reads the token, verifies its signature, and says "this is User A. Let them through."
Notice the key difference: the server never stores anything.
It doesn't look up a session in a database. It simply reads the token, checks its cryptographic signature to make sure nobody tampered with it, and extracts the user information. That's why JWTs are called stateless – the server doesn't maintain any state about who is logged in.
This solves the cross-domain problem because tokens are sent in the request header, not as cookies. Headers work the same way regardless of which domain the request comes from.
This solves the scalability problem because the server doesn't store sessions. Verifying a token is a quick cryptographic check, not a database lookup.
This solves the mobile problem because any client that can send HTTP headers can use JWT. Mobile apps, desktop apps, other servers – they all work the same way.
Step 1: How to Set Up the Project and Install the Dependecies
1.1 How to Create the Project
Open your terminal, navigate to where you want your project to live, and run the following commands:
mkdir notes-project
cd notes-project
1.2 How to Create a Virtual Environment and Install the Required Dependencies
You will create a virtual environment here. Type the following command:
python3 -m venv venv
The above command creates a virtual environment inside a folder called venv. The first venv is the command and the second venv represents the name of the folder. You can name the folder anything though venv is usually preferred.
To activate the virtual environment, we need to use the following command:
On macOS/Linux:
source venv/bin/activate
On Windows:
venv\Scripts\activate
You'll know it worked when you see (venv) at the beginning of your terminal prompt. From this point on, any Python packages you install will only exist inside this virtual environment.
With the virutal environment activated, install Django, Django Rest Framework, and Simple JWT Framework using the command:
pip install django djangorestframework djangorestframework-simplejwt
You can verify everything installed correctly by running:
pip list
You should see all three packages listed along with their dependencies.
1.3 How to Create the Project and the App
Run the following command to create the Django project:
django-admin startproject notes_core .
The dot at the end is important. It tells Django to create the project files in your current directory instead of creating an extra nested folder.
Now let's type this command to create the app:
python manage.py startapp notes
1.4 How to Register the App and Django Rest Framework (DRF)
Open notes_core/settings.py and add rest_framework and notes in the INSTALLED_APPS list:
Django now knows about your new app and the REST framework. Let's move on to the most important architectural decision you'll make for this project.
Step 2: How to Create a Custom User Model
If you've built Django projects before, you might have used Django's default User model. For quick prototypes, that works fine. But for any project you plan to grow or maintain, starting with a custom user model is a best practice you should never skip.
Here's why: Django's default User model uses a username field as the primary identifier. If you later decide you want users to log in with their email address instead, or you need to add a profile picture field, or a phone number, then you're stuck.
Using a custom user model gives you full control over what a "user" means in your app. Instead of being tied to a username, you can design login around something more practical, like email or phone_number for a fitness or mobile-based app. You can also include fields like role (doctor, patient, receptionist in a clinic system) or date of birth directly in the user model, instead of managing a separate profile.
It also helps future-proof your project. If you start with the default model and later decide to switch login from username to email, or add required fields, it becomes difficult and risky to change. Using a custom user model from the beginning avoids this problem and makes it much easier to adapt your authentication system as your app grows.
By creating a custom user model from the start, even if it's identical to the default one, you give yourself the freedom to make changes later without any of that pain.
2.1 How to Define the Custom User Model
Open notes/models/py and add the following code:
from django.contrib.auth.models import AbstractUser
from django.db import models
class CustomUser(AbstractUser):
pass
You are importing Django’s built-in AbstractUser class.
Think of AbstractUser as a ready-made blueprint for a user. It already includes fields like username, password, email, first name, last name , and authentication logic.
The pass statement means you're not adding any extra fields yet.
But the key point is that this model is yours. So this model behaves exactly like Django’s default user model, but with one big advantage: you now have the flexibility to customize it later.
If three months from now you need to add a phone_number field or switch to email-based login, you just add a field to this class and run a migration.
from django.contrib.auth.models import AbstractUser
from django.db import models
class CustomUser(AbstractUser):
phone_number = models.CharField(max_length=15)
You can also see all the fields that the CustomUser class has inherited from the AbstractUser class.
To do this we can use the Python shell. Type the following command:
python manage.py shell
When you type this command, make sure that the virtual environment is active:
After this, import the CustomUser model in the shell:
from notes.models import CustomUser
After that, type the following code:
[fields.name for field in CustomUser._meta.get_fields()]
The above statement lists out all the fields in the CustomUser class.
2.2 How to Tell Django to Use Your Custom User Model
Now comes the important bit. Open notes_core/settings.py and add this line:
AUTH_USER_MODEL = 'notes.CustomUser'
This setting tells Django to use your CustomUser model instead of the built-in one for everything authentication-related such as login, permissions, foreign keys, and so on.
There's no strict rule to where you need to add it, but the best practice is to add it near the end of the file.
You can see which user model Django is using by using the method get_user_model().
Open the Python shell again and import the get_user_model() method:
from django.contrib.auth import get_user_model
Then use get_user_model() and print the output:
user = get_user_model()
print(user)
You should see the name of our model being used:
If you hadn't added the AUTH_USER_MODEL in the settings.py file, then Django would have used the default user model:
Note: You'll need to do this before you run your first migration. If you run migrate before setting AUTH_USER_MODEL, Django creates tables for the default User model, and switching afterward becomes a headache.
2.3 How to Run Migrations
Now create and apply the initial migrations:
python manage.py makemigrations
python manage.py migrate
Django will create the necessary tables for your custom user model along with all the built-in Django tables.
We can again peek under the hood to see the SQL queries that Django used to create the tables especially the CustomUser table.
Type this command:
python manage.py sqlmigrate notes 0001
Here notes is the name of the app and 0001 represents the migration number.
And you should get this output:
Let's also create a superuser so you can access the admin panel later for debugging:
python manage.py createsuperuser
Fill in the username, email (optional), and password when prompted.
Step 3: How to Define the Note Model
Now let's create the data model for the core of your application. First add a new import to use the settings object.
from django.conf import settings
Then add the following code below the CustomUser class:
class Notes(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notes'
)
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.title} (by {self.owner.username})"
Here's the complete model.py code:
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.conf import settings
class CustomUser(AbstractUser):
pass
class Notes(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notes'
)
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.title} (by {self.owner.username})"
Let's walk through each field:
owner = models.ForeignKey(settings.AUTH_USER_MODEL, ...): Creates a relationship between each note and a user. TheForeignKeyfield tells Django that each note belogs to exactly one user but a user can have many notes.Notice that we use
settings.AUTH_USER_MODELinstead of directly importingCustomUser. This is the recommended practice because it keeps your code flexible. If you ever change the user model reference in settings, this foreign key adapts automatically.The
on_delete=models.CASCADEmeans that if a user is deleted, all their notes are deleted too.The
related_name='notes'lets you access a user's notes withuser.notes.all().title = models.CharField(max_length=200): Creates a text field for the task name, limited to 200 characters.body = models.TextField(): Holds the actual note content.TextFieldhas no character limit, so users can write as much as they need.created_at = models.DateTimeField(auto_now_add=True): Automatically records the date and time when a task is created. You never need to set this manually.The
__str__()method gives each note a readable representation. Instead of seeing "Note object (1)" in the admin panel or during debugging, you'll see something like "Meeting Notes (by Solina)."
3.2 How to Apply Migration
Run the migration commands to create the Note table:
python manage.py makemigrations
python manage.py migrate
As before, we can see the exact SQL query Django used to create the notes table:
3.3 How to Register Models in the Admin
Open notes/admin.py and register both models so you can inspect data through the admin panel:
from django.contrib import admin
from .models import CustomUser, Notes
admin.site.register(CustomUser)
admin.site.register(Notes)
This is helpful during development when you want to quickly check whether data is being saved correctly.
Step 4: How to Create the Serializer
In DRF, a serializer is like a bridge between your database and the internet.
Django models store data as Python objects. But when you want to send that data to a frontend application (like React or a mobile app), you can't send Python objects. You need to send a format that everyone understands which is usually JSON.
Serializers perform three main jobs:
Serialization: Converting complex Python objects (Models) into Python dictionaries (which can be easily rendered into JSON).
Deserialization: Converting JSON data coming from a user back into complex Python objects.
Validation: Checking if the incoming data is correct before saving it to the database.
4.1 How to Create UserSerializer
Create a new file called notes/serializers.py and add the following code:
from rest_framework import serializers
from django.contrib.auth import get_user_model
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'password']
def create(self, validated_data):
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password']
)
return user
Let's break down this serializer.
The
UserSerializerhandles user registration.User = get_user_model()gets the user model that you're using and stores in the variableUser. In our case, we're using theCustomUsermodelclass UserSerializer(serializers.ModelSerializer):: Here you've created the UserSerializer class, which inheritsModelSerializer.A
ModelSerializeris a shortcut that automatically creates a serializers class with fields that are in the model class.When we use a
ModelSerializer, DRF inspects the model and automatically does these things:1. Generates fields from the model so you don't have to
2. Automatically adds field validations that are present in the model
3. Implementscreate()andupdate()methods. AModelSerializerknows which model to use and how to update and create it. You can overridecreate()andupdate()methods if you need customized behaviors. You have overridden thecreate()method in the above code.password = serializers.CharField(write_only=True): This line is crucial. Thewrite_only=Trueflag means the password will be accepted during registration but will never appear in any API response. Without this, your API would send back the password (even if hashed) every time user data is returned.So users can create accounts, but their passwords are never exposed back.
class Meta: Inside theMetaclass, you tell the serializer which model to use. In this case, the model to use isUserand the fields to be handled.The
create()method: This is the most important part. This method runs when we create a new user. Instead of using the default.create()method you have overridden it.It's important to understand why we have overridden this method. The default
create()method is not suitable for creating users securely.By default this method stores the password in plain text format. This is a serious problem because passwords should never be stored in raw form. They need to be hashed so that even if the database is compromised, the passwords are never exposed.
Django provides a special method called
create_user()that automatically handles this by hashing the password and setting up the user properly for authentication.
4.2 How to Create NoteSerializer
After the UserSerializer class, let's create the NoteSerializer class. The NoteSerializer handles the notes data
First of all, you need to add an import to the Notes class. Add the line from .models import Notes at the end of the last import.
Put this code below the UserSerializer class:
class NoteSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
class Meta:
model = Notes
fields = ['id', 'owner', 'title', 'body', 'created_at']
Now let's break it down:
owner = serializers.ReadOnlyField(source='owner.username'): This is the most important line in the code. This makes theownerfield read-only. That means the API will display who owns a note (showing their username), but no one can set or change the owner through the API.Without this protection, a malicious user could send a POST request with
"owner": 5and assign their note to someone else's account, or worse, modify someone else's notes by reassigning ownership.The
source='owner.username'part tells DRF to display the owner's username instead of their numeric ID, which makes the API responses more readable.class Meta:...: As before theMetaclass contains the model which the serializer use and the fields that the API will expose.Here is the complete code in the
serializers.pyfile
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Notes
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'password']
def create(self, validated_data):
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password']
)
return user
class NoteSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
class Meta:
model = Notes
fields = ['id', 'owner', 'title', 'body', 'created_at']
Step 5: How to Configure SimpleJWT
Now let's set up the authentication system. This is where you tell DRF to use JWT for authentication instead of sessions. This step is crucial because without it, DRF will default to session-based auth.
SimpleJWT provides a complete JWT implementation for DRF, so you don't have to build token generation, signing, or verification from scratch.
The access token is what your client sends with every API request. It's short-lived by design. Think of it like a visitor badge at an office building: it gets you through the door, but it expires at the end of the day. If someone steals it, the damage is limited because it stops working soon.
The refresh token is longer-lived and has a single purpose: getting a new access token when the current one expires. The client stores it securely and only sends it to one specific endpoint. Think of it like your employee ID card. You use it to get a new visitor badge each morning, but you don't flash it at every door.
This separation exists for security. If the short-lived access token is compromised (which is more likely since it's sent with every request), the attacker has a narrow window before it expires. The refresh token, which is sent less frequently, has a lower risk of interception.
Let's look at how the access and refresh token work together
User logs in, server gives both access token and refresh token
User makes requests using the access token
Access token expires
App sends refresh token to server
Server checks it and gives a new access token
User continues without logging in again
5.1 How to Update REST Framework Settings
Open notes_core/settings.py and add the following code:
from datetime import timedelta
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
}
Let's unpack what each section does.
The DEFAULT_AUTHENTICATION_CLASSES setting tells DRF to use JWT as the authentication method for all API endpoints. Every incoming request will be checked for a valid JWT token in the Authorization header.
The DEFAULT_PERMISSION_CLASSES setting sets IsAuthenticated as the global permission policy. This means every endpoint in your API is locked down by default. Only users with a valid token can access any endpoint.
This is a secure-by-default approach: instead of remembering to protect each view individually, everything is protected, and you explicitly open up the endpoints that need to be public (like the registration endpoint, which you'll handle in the next step).
The SIMPLE_JWT dictionary controls token behavior. The access token lasts 30 minutes. This is the token clients include in every request. If someone intercepts it, the damage is limited to a 30-minute window. The refresh token lasts one day.
When the access token expires, the client can use the refresh token to get a new access token without forcing the user to log in again. The duration of the refresh token is 1 day. This means after 1 day, the user must log in again with their username and password. You'll see exactly how this works later when you test with Postman.
5.2 How to Add Token URL Endpoints
SimpleJWT provides ready-made views for obtaining and refreshing tokens. You just need to wire them up to URLs.
Open notes_core/urls.py and update it with the following code:
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('notes.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
The token/ endpoint accepts a username and password, and returns an access token and a refresh token.
The token/refresh/ endpoint accepts a refresh token and returns a new access token. You'll see these in action during testing.
Step 6: How to Build the Authentication Logic
Open notes/views.py and add the following:
from rest_framework import generics, permissions
from django.contrib.auth import get_user_model
from .serializers import UserSerializer
User = get_user_model()
class RegisterView(generics.CreateAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.AllowAny]
Now let's walk through this code.
The first section are the imports and after that we have used the the get_user_model() method to get the CustomUser model.
Now the main part is RegisterView class. The class inherits from generics.CreateAPIView which is a built in DRF view designed specifically for handling POST requests that create new objects.
Because of this, you don’t have to manually write the logic for handling POST requests, validating data, or saving to the database. DRF does all of that for you behind the scenes.
Inside the class, queryset = Users.objects.all() defines the set of user objects this view can work with.
The serializer_class = UserSerializer tells the view which serializer to use for validating incoming data and creating the user.
Finally permission_classes = [permissions.AllowAny] overrides the global IsAuthenticated permission you set earlier in the value of DEFAULT_PERMISSION_CLASSES .
This means that anyone can access the registration endpoint, even if they aren't logged in. This makes sense for a registration endpoint because new users won’t have accounts yet.
Every other view in your API will inherit the global IsAuthenticated permission, so only this registration endpoint is open.
Step 7: How to Implement Scoped Views
This is the heart of the tutorial. You've set up authentication so the API knows who is making a request. Now you need to make sure each user can only interact with their own notes.
Think of it this way: authentication is the lock on the front door of an apartment building. It keeps strangers out. But scoping is the lock on each individual apartment. Just because you live in the building doesn't mean you can walk into your neighbor's apartment.
Without scoping, an authenticated user could potentially see every note in the database, or worse, modify notes that belong to someone else. Two method overrides on your viewset prevent this entirely.
7.1 How to Create a NoteViewSet
Now let's create the NoteViewSet. First add these imports to the top of the file. We're importing the viewsets, serializers, and model.
from .models import Note
from .serializers import UserSerializer, NoteSerializer
from rest_framework import generics, viewsets, permissions
Add the following to notes/views.py, below the RegisterView:
class NoteViewSet(viewsets.ModelViewSet):
serializer_class = NoteSerializer
def get_queryset(self):
return Notes.objects.filter(owner=self.request.user).order_by('-created_at')
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
Now let's talk about this code in detail.
You've created a new class called NoteViewSet which inherits from the DRF class ModelViewSet. This gives you full CRUD operations, meaning you can list notes and retrieve a single note, as well as create, update, and delete a note.
The next part serializer_class = NoteSerializer tells Django to use the NoteSerializer class to convert between Python objects and JSON.
But the magic is the two methods that you are overriding: get_queryset() and perform_create().
The get_queryset() method controls which notes the API returns. If you didn't override this method, it would return Note.objects.all() (which would give every user access to every note in the database).
But here, you've overridden this method so that it filters notes by the current user.
Next is the perform_create() method, which is called when the note is saved. You've overridden this method so that it saves the notes of the user who's currently logged in. If you hadn't overridden the this method, it would return all the notes regardless of the logged in user.
Notice that you have passed self.request.user parameters in to the filter() function. This is the code that attaches the logged-in user as the owner of the note.
Remember how you made the owner field read-only in the serializer? This is the other half of that security measure.
The user can't set the owner through the API request, and the server automatically sets it to whoever is authenticated. These two pieces work together to make ownership tamper-proof.
7.2 Why This Matters: Preventing ID Enumeration Attacks
Without get_queryset filtering, your API might allow something like this: a user sends a GET request to /api/notes/42/ and sees a note that belongs to someone else, simply because they guessed the ID.
This is called an ID enumeration attack — an attacker cycles through IDs (1, 2, 3, 4...) to discover and access other people's data.
With your scoped get_queryset, even if User B sends a request to /api/notes/42/ and note 42 belongs to User A, the viewset won't find it in User B's filtered queryset. DRF will return a 404 — as far as User B is concerned, that note doesn't exist.
Step 8: How to Connect a URL
Now you need to wire up the views to URL paths so the API knows which view to call for each endpoint.
8.1 How to Create App-level URLs
Create a new file called notes/urls.py and add the following:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RegisterView, NoteViewSet
router = DefaultRouter()
router.register(r'notes', NoteViewSet, basename='note')
urlpatterns = [
path('register/', RegisterView.as_view(), name='register'),
path('', include(router.urls)),
]
The DefaultRouter automatically generates URL patterns for the NoteViewSet. Since you're using a ModelViewSet, the router creates endpoints for listing all notes, creating a note, retrieving a single note, updating a note, and deleting a note — all from that single router.register call.
The basename='note' parameter is required here because your viewset doesn't have a queryset attribute defined directly on the class (you're using get_queryset instead). DRF uses the basename to generate the URL pattern names like note-list and note-detail.
8.2 How to Verify the Project-Level URLs
Make sure your notes_core/urls.py looks like this (you set this up in Step 5, but let's confirm):
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('notes.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
Here's the full picture of your API's URL structure:
| Endpoint | Method | Description |
|---|---|---|
api/register/ |
POST | Create a new user account |
api/token/ |
POST | Get access and refresh tokens |
api/token/refresh/ |
POST | Get a new access token using a refresh token |
api/notes/ |
GET | List all notes for the authenticated user |
api/notes/ |
POST | Create a new note |
api/notes/<id>/ |
GET | Retrieve a specific note |
api/notes/<id>/ |
PUT/PATCH | Update a specific note |
api/notes/<id>/ |
DELETE | Delete a specific note |
Start the development server to make sure everything runs without errors:
python manage.py runserver
If the server starts without complaints, your code is wired up correctly.
Step 9: How to Test the APIs with Postman
Building the API is one thing. Proving it works is another. Let's walk through the entire flow using Postman, from registering a user to demonstrating that scoping actually works.
If you haven't used Postman before, it's a tool that lets you send HTTP requests to your API and inspect the responses. You can download it from postman.com/downloads.
Alternatively, you can use curl from the command line or any other API testing tool you're comfortable with.
Make sure your development server is running before proceeding.
9.1 How to Register a User
Open Postman:
Create a new request:
| Method | POST |
|---|---|
| URL | http://127.0.0.1:8000/api/register/ |
| Body tab | Select "raw" and choose "JSON" from the dropdown |
| Body Content | { "username": "priya", "email": "priya@example.com", "password": "securepassword123" } |
Click Send. You should get a 201 Created response with the user data (without the password, thanks to your write_only=True field) which you wrote in the UserSerializer class.
9.2 How to Obtain Access and Refresh Tokens
Now log in to get your JWTs:
| Method | POST |
|---|---|
| URL | http://127.0.0.1:8000/api/token/ |
| Body | {"username" : "priya", "password" : "securepassword123"} |
You'll get a response with access and refresh tokens.
Copy the access token. You'll need it for every subsequent request. Also save the refresh token, as you'll use it later.
A JWT is only encoded and not encrypted. The encoding is merely a way to transform the data into a safe, standard string format that can be easily transmitted over the internet.
Any one can peel through the encoding to see the data. This is done using base64url encoding.
We can use the Python library pyjwt to decode JWTs or use any of the online sites to decode. It's important to note that you should use online sites with caution since JWTs may contain sensitive information.
For this demo, we'll use site called jwt.io.
Open the site and paste in the access token that you have just created:
The JWT has three parts: the header, the payload, and the signature.
The header sections tells you how the header is signed. In this case it is signed using the HS256 algorithm.
The payload is where the actual data or claim lives. It contains standard claims such as token types, expiration time ( exp ), issued at time ( iat ), and custom claims.
The signature section is used to verify integrity. You can't decode it to meaningful data. This section ensures that the token wasn't tampered with.
9.3 How to Create a Note
Now use the access token to create a note:
| Method | POST |
|---|---|
| URL | http://127.0.0.1:8000/api/notes/ |
| Header tab: | Add a new header: |
| Key: Authorization, Value: Bearer | |
| Body | {'title': 'My note', 'body': 'This contains secret information'} |
Notice that you don't include an owner field. That's handled automatically by perform_create. You should get a 201 Created response:
You can create a few more notes, so that we have some data to work with.
9.4 How to List Your Notes
Now to fetch all of Priya's notes:
| Method | GET |
|---|---|
| URL | http://127.0.0.1:8000/api/notes/ |
| Header tab: | Same Authorization: Bearer header |
You should see all the notes created, sorted by most recent first.
9.5 How to Demonstrate Scoping
Let's prove that a second user can't view the first user's notes.
First, register the second user.
Send a POST request to http://127.0.0.1/api/register with the following data:
| Method | POST |
|---|---|
| URL | http://127.0.0.1:8000/api/register/ |
| Body tab | Select "raw" and choose "JSON" from the dropdown |
| Body Content | { "username": "sujan", "email": "sujan@example.com", "password": "anotherpassword123" } |
Then get tokens for Sujan by sending a POST request to http://127.0.0.1:8000/api/token/ with Sujan's credentials (username and password) and then copy Sujan's access token.
Now send a GET request to http://127.0.0.1:8000/api/notes/ using Sujan's token in the Authorization header.
The response should be an empty list since this user hasn't created any notes:
More importantly, Priya's notes are completely invisible to him. Even if Sujan tries to access a specific note by ID – say, http://127.0.0.1:8000/api/notes/1/ – he'll get a 404 Not Found response, not a 403 Forbidden.
This is intentional. A 404 Not Found doesn't reveal that the note exists, while a 403 Forbidden would confirm its existence to a potential attacker.
A 403 Forbidden response is like a door with a sign: “Authorized personnel only”. You now know something important is inside. A 404 Not Found response is like a blank wall. You don’t even know a room exists.
Now that you know why we've used the 404 response instead of 403, let's demonstrate this.
First, I'll access Priya's individual note using her credentials and her access token:
Now, I'll change the access token and put Sujan's (new user) access token:
You can see that using the new user's token to access the previous user's note leads to 404 Not Found response.
Step 10: How to Handle Token Expiration with Refresh Tokens
Access tokens are deliberately short-lived (30 minutes in your configuration). This limits the window of damage if a token is stolen.
But you don't want users to re-enter their credentials every 30 minutes. That's what refresh tokens are for.
When Priya's access token expires, her API requests will start returning 401 Unauthorized responses. Instead of logging in again, the client sends the refresh token to get a fresh access token.
| Method | POST |
|---|---|
| URL | http://127.0.0.1:8000/api/token/refresh/ |
| Body tab | Select "raw" and choose "JSON" from the dropdown |
| Body Content | { refresh: < Priya's refresh token >} |
Replace your old access token with this new one, and you're good for another 30 minutes. The refresh token itself lasts for one day, so the user only needs to fully log in again once every 24 hours.
In a real application, the frontend client handles this automatically. When an API call returns a 401, the client catches it, sends the refresh token to get a new access token, and retries the original request — all without the user noticing.
Here's what that flow looks like in pseudocode:
Client sends request with access token
Server responds with 401 (token expired)
Client sends refresh token to /api/token/refresh/
Server responds with a new access token
Client retries the original request with the new access token
Server responds with the data

If the refresh token itself has expired (after 24 hours in your configuration), step 4 will also return a 401. At that point, the user truly needs to log in again with their username and password. This is the intended behavior: it means even a stolen refresh token has a limited useful life.
How You Can Improve This Project
This API is functional and secure, but there's plenty of room to build on it. Here are some directions you could take.
Add search and filtering. Let users search their notes by title or body text. You can use DRF's SearchFilter and django-filter to add query parameters like
?search=meetingto the notes list endpoint.Add categories or tags. Create a
Categorymodel and add a foreign key toNote, or use a many-to-many relationship for tags. This would let users organize their notes and filter by category.Add pagination. Once a user has hundreds of notes, returning them all in a single response becomes slow. DRF has built-in pagination classes that let you return notes in pages of 10, 20, or whatever size you choose.
Deploy to a production server. The API currently runs on your local machine. You could deploy it to platforms like PythonAnywhere, Railway, or Render to make it accessible from anywhere. You'd need to configure a production database (like PostgreSQL), set a secure SECRET_KEY, and serve the application behind HTTPS.
Build a frontend. Connect a React, Next.js, or Vue.js frontend to this API. Store the JWTs in the client and implement the token refresh flow so users stay logged in seamlessly.
Add token blacklisting. SimpleJWT supports token blacklisting, which lets you invalidate refresh tokens when a user logs out. Without this, a refresh token remains valid until it expires, even after the user "logs out."
Each of these improvements builds on the patterns you've already learned and will deepen your understanding of Django, DRF, and API design.
Conclusion
You've built a fully functional, secure note-taking API with Django, Django REST Framework, and SimpleJWT. Along the way, you learned some fundamental concepts that apply to any API you'll build in the future.
You started with a custom user model — a small decision at the beginning that saves you from a painful migration later. You configured JWT authentication so your API can serve mobile clients and decoupled frontends that can't rely on session cookies.
You built serializers that protect sensitive data by keeping passwords write-only and ownership read-only. Most importantly, you implemented scoped views that ensure each user's data is completely isolated from everyone else's.
The patterns you practiced here — overriding get_queryset to filter by the current user, overriding perform_create to assign ownership automatically, and using read-only fields to prevent data tampering — are the same patterns you'll use in production APIs handling real user data.
The best way to solidify what you've learned is to keep building. Try adding search and filtering, build a React frontend that consumes this API, or start a completely new project may be a task manager, a journal app, or a bookmarks API using the same JWT and scoping patterns. The core workflow stays the same. Only the models and business logic change.