How to Build a Scoped Note
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, -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 scopingcomes 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:
Prerequisities
What is JWT and Why Use It Over Session Authentication?
How Session Authentication Works
How JWT Authentication Works
Step 1: How to Set Up the Project and Install the Dependecies
1.1 How to Create the Project
1.2 How to Create a Virtual Environment and Install the Required Dependencies
1.3 How to Create the Project and the App
1.4 How to Register the App and Django Rest Framework (DRF)
Step 2: How to Create a Custom User Model
2.1 How to Define the Custom User Model
2.2 How to Tell Django to Use Your Custom User Model
2.3 How to Run Migrations
Step 3: How to Define the Note Model
3.2 How to Apply Migration
3.3 How to Register Models in the Admin
Step 4: How to Create the Serializer
4.1 How to Create UserSerializer
4.2 How to Create NoteSerializer
Step 5: How to Configure SimpleJWT
5.1 How to Update REST Framework Settings
5.2 How to Add Token URL Endpoints
Step 6: How to Build the Authentication Logic
Step 7: How to Implement Scoped Views
7.1 How to Create a NoteViewSet
7.2 Why This Matters: Preventing ID Enumeration Attacks
Step 8: How to Connect a URL
8.1 How to Create App-level URLs
8.2 How to Verify the Project-Level URLs
Step 9: How to Test the APIs with Postman
9.1 How to Register a User
9.2 How to Obtain Access and Refresh Tokens
9.3 How to Create a Note
9.4 How to List Your Notes
9.5 How to Demostrate Scoping
Step 10: How to Handle Token Expiration with Refresh Tokens
How You Can Improve This Project
Conclusion
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 sessionwhich is a small record stored in the server's database that says "this user is logged in."
The server sends back a session IDas a . The browser stores this automatically.
On every subsequent request, the browser sends the 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 s 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, s become tricky. Browsers enforce strict rules about which domains can send and receive s.
You can work around this with CORS (Cross-Origin Resource Sharing) headers and special 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 s the same way browsers do. If you're building an API that will serve both a web app and a mobile app, session s 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 problembecause tokens are sent in the request header, not as s. Headers work the same way regardless of which domain the request comes from.
This solves the scalability problembecause the server doesn't store sessions. Verifying a token is a quick cryptographic check, not a database lookup.
This solves the mobile problembecause 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-projectcd 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 venvis the command and the second venvrepresents the name of the folder. You can name the folder anything though venvis usually preferred.
To activate the virtual environment, we need to use the following command:
On macOS/Linux:
source venv/bin/activateOn Windows:
venv\Scripts\activateYou'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 listYou 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.pyand add rest_frameworkand notesin the INSTALLED_APPSlist:

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 Usermodel uses a usernamefield 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/pyand add the following code:
from django.contrib.auth.models import AbstractUserfrom django.db import modelsclass CustomUser(AbstractUser): pass
You are importing Django’s built-in AbstractUserclass.
Think of AbstractUseras a ready-made blueprint for a user. It already includes fields like username, password, email, first name, last name , and authentication logic.
The passstatement 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_numberfield or switch to email-based login, you just add a field to this class and run a migration.
from django.contrib.auth.models import AbstractUserfrom django.db import modelsclass CustomUser(AbstractUser): phone_number = models.CharField(max_length=15)You can also see all the fields that the CustomUserclass has inherited from the AbstractUserclass.
To do this we can use the Python shell. Type the following command:
python manage.py shellWhen you type this command, make sure that the virtual environment is active:

After this, import the CustomUsermodel in the shell:
from notes.models import CustomUserAfter that, type the following code:
[fields.name for field in CustomUser._meta.get_fields()]The above statement lists out all the fields in the CustomUserclass.

2.2 How to Tell Django to Use Your Custom User Model
Now comes the important bit. Open notes_core/settings.pyand add this line:
AUTH_USER_MODEL = 'notes.CustomUser'This setting tells Django to use your CustomUsermodel 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_MODELin the settings.pyfile, 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 makemigrationspython 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 CustomUsertable.
Type this command:
python manage.py sqlmigrate notes 0001Here notesis the name of the app and 0001represents 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 createsuperuserFill 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 settingsobject.
from django.conf import settingsThen add the following code below the CustomUserclass:
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.pycode:
from django.contrib.auth.models import AbstractUserfrom django.db import modelsfrom django.conf import settingsclass CustomUser(AbstractUser): passclass 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 makemigrationspython manage.py migrate
As before, we can see the exact SQL query Django used to create the notestable:

3.3 How to Register Models in the Admin
Open notes/admin.pyand register both models so you can inspect data through the admin panel:
from django.contrib import adminfrom .models import CustomUser, Notesadmin.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.pyand add the following code:
from rest_framework import serializersfrom django.contrib.auth import get_user_modelUser = 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 userLet'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 neverappear 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 hashedso 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 passwordand setting up the user properly for authentication.

4.2 How to Create NoteSerializer
After the UserSerializerclass, let's create the NoteSerializerclass. The NoteSerializerhandles the notes data
First of all, you need to add an import to the Notesclass. Add the line from .models import Notesat the end of the last import.
Put this code below the UserSerializerclass:
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 serializersfrom django.contrib.auth import get_user_modelfrom .models import NotesUser = 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 userclass 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.pyand add the following code:
from datetime import timedeltaREST_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_CLASSESsetting 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_CLASSESsetting sets IsAuthenticatedas 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_JWTdictionary 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.pyand update it with the following code:
from django.contrib import adminfrom django.urls import path, includefrom 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.pyand add the following:
from rest_framework import generics, permissionsfrom django.contrib.auth import get_user_modelfrom .serializers import UserSerializerUser = 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 CustomUsermodel.
Now the main part is RegisterViewclass. The class inherits from generics.CreateAPIViewwhich 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 = UserSerializertells the view which serializer to use for validating incoming data and creating the user.
Finally permission_classes = [permissions.AllowAny]overrides the global IsAuthenticatedpermission 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 whois making a request. Now you need to make sure each user can only interact with theirownnotes.
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 Notefrom .serializers import UserSerializer, NoteSerializerfrom rest_framework import generics, viewsets, permissionsAdd 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 NoteViewSetwhich 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 = NoteSerializertells Django to use the NoteSerializerclass 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.userparameters 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.pyand add the following:
from django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom .views import RegisterView, NoteViewSetrouter = DefaultRouter()router.register(r'notes', NoteViewSet, basename='note')urlpatterns = [ path('register/', RegisterView.as_view(), name='register'), path('', include(router.urls)),]The DefaultRouterautomatically 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 basenameto generate the URL pattern names like note-listand note-detail.
8.2 How to Verify the Project-Level URLs
Make sure your notes_core/urls.pylooks like this (you set this up in Step 5, but let's confirm):
from django.contrib import adminfrom django.urls import path, includefrom 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 runserverIf 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": "[email protected]", "password": "securepassword123" } |

Click Send. You should get a 201 Createdresponse with the user data (without the password, thanks to your write_only=Truefield) which you wrote in the UserSerializerclass.


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 pyjwtto 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 HS256algorithm.
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/registerwith 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": "[email protected]", "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 Foundresponse, not a 403 Forbidden.
This is intentional. A 404 Not Founddoesn't reveal that the note exists, while a 403 Forbiddenwould confirm its existence to a potential attacker.
A 403 Forbiddenresponse is like a door with a sign: “Authorized personnel only”.You now know something important is inside. A 404 Not Foundresponse is like a blank wall. You don’t even know a room exists.

Now that you know why we've used the 404response 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 Foundresponse.
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 Unauthorizedresponses. 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 keytoNote, 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 s.
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_querysetto filter by the current user, overriding perform_createto assign ownership automatically, and using read-onlyfields 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.
More From This Topic
View Topic
How to Run Private Text
June 14, 2026 / #M …
Command Line for Beginners – How to Use the Terminal Like a Pro [Full Handbook]
April 5, 2022 / #B …
Key Technical Design Decisions for Building an Educational App with LLMs
June 3, 2026 / #ed …
The REST API Handbook – How to Build, Test, Consume, and Document REST APIs
April 27, 2023 / # …
Java Enterprise Development Tools
Workflow automation eliminates manual processes and reduces errors. Business process management tool …
How to Write Clean Code – Tips and Best Practices (Full Handbook)
May 15, 2023 / #be …