Sitemap

How I Built My Own Backend being an iOS Developer

11 min readAug 11, 2025

For those who don’t know me, I’m an iOS dev. I live and breathe SwiftUI, UIKit, Combine… and the backend was always that scary and mysterious world I just used. So if you’re a backend dev reading this and thinking “hmm, there’s a better way to do this”, you’re probably right! This isn’t a tutorial, but a personal log of how I built my first backend from scratch using FastAPI. And if you’re a mobile, frontend, or beginner backend dev, I hope this helps or inspires you to explore the other side of the API.

Why a Todo List? And Why Backend?

The idea for this backend came from my personal need to learn more about networking layers for large-scale iOS apps. I started with a simple todo-list app to consume an API, but instead of using a ready-made backend, I decided to take my learning further: not only would I understand the networking layer, but I’d also learn how to build my own backend and see how this whole area works in a practical, hands-on way.

I’ve always been a mobile dev, used to consuming ready-made APIs. But I felt the need to understand what happens on the other side. How do you protect data? How do you ensure each user only sees their own stuff? How do you create a decent authentication system? These questions pushed me out of my comfort zone.

What Are We Building?

The idea was to create a system where:

  • Each user has their own login (username and password)
  • Each user can only see and manage their own tasks
  • Tasks can be organized into categories (like “Work,” “Personal,” “Study”)
  • Everything is protected by modern authentication (OAuth2.0 and JWT)

This kind of app is great for learning because you practice CRUD, learn to protect each user’s data, work with table relationships, and can expand to more advanced features.

Choosing the Stack

I wanted something modern, but not a pain to set up. That’s where FastAPI won me over. It offers automatic documentation with both Swagger and ReDoc, which means I get a fully interactive API explorer out of the box, with zero effort. Plus, the community is friendly and active, which made it easier to find answers and learn as I went.

For the data layer, I took a chance on SQLModel. I had never used it before, but the idea was too good to ignore. It combines the power of SQLAlchemy (a battle-tested ORM) with the simplicity and type safety of Pydantic (which handles data validation and serialization). With SQLModel, you write a single class that works as both a database model and API schema, using type hints and getting automatic validation for free.

As for the database, I went with SQLite, partly out of convenience, partly out of laziness (shout-out to Rafael for the nudge). I didn’t want to waste time setting up a full database server or messing with Docker; I just wanted to see the app running, and SQLite let me do that instantly.

Here’s an example of how I modeled the data using SQLModel. I’ll explain each line and decision:

from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
from uuid import uuid4
from datetime import date

class User(UserBase, table=True):
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
hashed_password: str
disabled: Optional[bool] = Field(default=False)

Explaining each field:

  • id: The unique identifier for the user. I use uuid4() to avoid collisions and make future integrations easier. Field is a SQLModel function that lets you configure field details, like default value, primary key, etc..
  • username: The user’s login. It could be email, but I chose a username for simplicity.
  • name: Real name, just for display.
  • hashed_password: Never, ever, store plain text passwords! Always store the hash. The hash is generated with bcrypt at registration.
  • disabled: A boolean field to indicate if the user is active. Useful for banning or suspending without deleting.
class Category(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
name: str
created_at: date
todos: List["Todo"] = Relationship(back_populates="category") # Relationship: a category has many tasks
  • todos: The Relationship field maps the relationship between tables. Here, a category can have many tasks (todos). back_populates indicates the field name in the other table that does the reverse link.
class Todo(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
username: str
content: str
completed: bool = False
created_at: date
category_id: Optional[str] = Field(default=None, foreign_key="category.id") # FK to category
category: Optional[Category] = Relationship(back_populates="todos") # Reverse relationship
  • category_id: The foreign_key indicates this field references the id field in the category table.
  • category: The Relationship links to access the task’s category directly.

What is an ORM? And What is SQLModel?

Earlier, I talked about SQLModel, SQLAlchemy, and Pydantic. Alright, let me explain a little bit better:

ORM stands for “Object Relational Mapper.” It’s a layer that translates Python objects (or from another language) into relational database tables. This way, you don’t have to write raw SQL for everything: you can manipulate data as objects, and the ORM handles the SQL under the hood.

SQLModel is a modern ORM, created by the same author as FastAPI. It combines the power of SQLAlchemy (one of Python’s most famous ORMs) with the simplicity and validation of Pydantic. This means you define your models once, and they serve both for the database and for data validation in your routes.

Just a heads-up:

SQLModel is a great fit for prototyping and small to medium projects thanks to its simplicity and FastAPI integration. However, for large-scale applications, using plain SQLAlchemy might give you more flexibility and advanced features.

Setting Up the Database

SQLite is a “file-based” relational database: everything is in a single file. No need to install a server, run Docker, nothing. Perfect for learning, testing, and even small apps.

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)
  • sqlite_url: The connection string that tells SQLAlchemy/SQLModel where the database is.
  • create_engine: Creates the database connection.
  • create_all: Creates all tables defined in the models.

Dependency Injection in FastAPI

FastAPI uses dependency injection to automatically provide certain objects or logic to your route functions without you having to create them manually each time. This is done with the Depends() function. A common example is providing a database session to a route. Instead of opening and closing the session inside every endpoint, we define it once:

from sqlmodel import create_engine, Session

engine = create_engine(sqlite_url, connect_args=connect_args)

def get_db():
with Session(engine) as session:
yield session

SessionDep = Annotated[Session, Depends(get_db)]

Here, get_db() yields a database session bound to your engine, and SessionDep is a type alias that makes route signatures cleaner. FastAPI calls get_db() automatically when the route runs and disposes of the session afterward.

The same mechanism applies to authentication. In our case, all routes are protected by Depends(get_current_active_user)(you’ll see that later), which means FastAPI will first check that the request comes from an authenticated and active user before the route code executes. This keeps security checks and resource setup separate from business logic, making the code both safer and easier to maintain.

Security and Authentication Flow

First things first: what exactly are OAuth2 and JWT that I mentioned earlier?

OAuth2 is a modern authorization protocol used by big companies like Google and Facebook. It defines how apps can authenticate users securely without storing passwords in memory or sessions.

JWT (JSON Web Token) is a digitally signed token format. After a successful login, the backend generates a JWT and sends it back to the client. This token is then attached to each subsequent request, allowing the backend to validate and authorize access.

Why is this good?

  • The backend doesn’t need to store session state in memory
  • The token can expire, be revoked, etc
  • It’s easy to integrate with mobile apps, web, etc

How I Handled Authentication and Security

When I started building this backend, one of my main concerns was doing authentication “the right way”, not just making it work. I wanted to understand what a secure system actually looks like, even in a small project like this.

Password Management

At the core of it all is password hashing. When a user creates an account, I never store their password directly. Instead, I use the bcrypt algorithm, via the passlib library, to create a secure hash. Bcrypt is designed for password hashing and automatically includes a unique salt, which means that even if two users pick the same password, their hashed values will be completely different.

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

And when a user logs in, I verify their password using the same hashing context. The verify_password function compares the plain-text password with the hashed one stored in the database:

def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)

Token Creation

Once the user is authenticated, I generate a JWT token to manage their session. This token contains user-identifying data (usually just the username) and an expiration timestamp. It’s then signed using a secret key so no one can tamper with it.

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

This token acts like a secure, compact passport for the user, it tells the server who the user is and whether the token is still valid, without needing to store any state on the server.

Of course, the SECRET_KEY used to sign tokens must be kept safe. In my case, it’s stored in environment variables, not hardcoded in the codebase, a basic, but essential best practice.

Token Verification

Every time the client makes a request to a protected endpoint, it includes the JWT in the Authorization header (as Bearer <token>). On the server side, I verify the token's authenticity with this function:

def verify_token(token: str) -> bool:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return bool(payload.get("sub"))
except InvalidTokenError:
return False

This verification checks:

  • That the signature is valid (i.e., it wasn’t tampered with)
  • That the token hasn’t expired
  • That the token contains a valid “subject” (usually the username)

Token Revocation

I also added a basic token revocation mechanism. Let’s say the user logs out or I need to force-expire their token. I can add it to a set of revoked tokens, and any future requests using that token will be denied.

In this project, I used an in-memory set for simplicity. But in a production environment, this list should definitely live in a persistent storage.

Security Takeaways

There are a few key security takeaways I tried to follow throughout the project:

  • Keep the SECRET_KEY and other sensitive settinsgs in environment variables
  • Set a reasonable expiration time for tokens (I used 30 minutes)
  • Never store plain-text passwords, always hash them with a strong algorithm like bcrypt
  • Be aware of how token revocation and refresh could work in more complex systems

Creating the Routes

After setting up the models and the authentication system, I started building the API routes. In my backend, all routes are protected, which means the client must send a valid JWT token to access any resource. This protection is enforced by using the dependency Depends(get_current_active_user) in the route parameters.

Here’s how the authentication dependencies work behind the scenes:

from app.core.security import get_current_active_user

async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if is_token_revoked(token):
raise HTTPException(status_code=401, detail="Token has been revoked")

try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
raise credentials_exception
except InvalidTokenError:
raise credentials_exception

user = get_user(db, username)
if not user:
raise credentials_exception
return user


async def get_current_active_user(
current_user: User = Depends(get_current_user),
token: str = Depends(oauth2_scheme)
) -> User:
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")

if is_token_revoked(token):
raise HTTPException(status_code=401, detail="Token has been revoked")

return current_user

How it works:

  • get_current_user is responsible for the heavy lifting: it extracts the JWT token from the request header, checks if the token is revoked, decodes it to get the username, and fetches the user from the database. If anything fails — invalid token, revoked token, user not found — it raises an HTTP 401 error.
  • get_current_active_user depends on get_current_user and adds two extra checks: it verifies the user is not disabled, and once again ensures the token hasn’t been revoked (extra safety). If any of these checks fail, it raises an HTTP 400 or 401 error respectively.

Using Depends(get_current_active_user) in routes ensures requests come from valid, active users with valid tokens before the route logic runs, making your endpoints secure by default.

Listing Todos

This route returns all todos that belong to the currently logged-in user:

@router.get("/todos", response_model=List[Todo], tags=["todos"])
async def get_todos(
session: SessionDep,
current_user: User = Depends(get_current_active_user)
) -> List[Todo]:
todos = session.exec(
select(Todo).where(Todo.username == current_user.username)
).all()
return todos

What happens here:

  • @router.get("/todos") creates a GET endpoint at /todos.
  • response_model=List[Todo] tells FastAPI to return a list of Todo objects, automatically serializing the response.
  • The session parameter injects the database session via dependency injection.
  • current_user ensures the user is authenticated and active before running the route logic.
  • The query filters todos so each user only gets their own tasks.

Creating a Todo

@router.post("/todos", response_model=Todo, tags=["todos"], status_code=201)
async def add_todo(
session: SessionDep,
todo: Todo,
current_user: User = Depends(get_current_active_user)
) -> Todo:
todo.username = current_user.username

todo.id = str(UUID(todo.id)) if isinstance(todo.id, UUID) else str(todo.id)

if isinstance(todo.created_at, str):
try:
todo.created_at = datetime.strptime(todo.created_at, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")

session.add(todo)
session.commit()
session.refresh(todo)
return todo

Here’s what’s going on:

  • The route is a POST to /todos, with a 201 status code to signal resource creation.
  • The todo parameter expects the todo data sent by the client, automatically validated against the Todo model.
  • I override the username on the incoming todo with the currently authenticated user, ensuring no one can create tasks for someone else.
  • I make sure the id and created_at fields are properly formatted, with simple validation to catch date format errors.
  • Then, the todo is added to the database session, committed, and refreshed to return the saved object with any auto-generated fields.

These two routes form the basic read and create operations for todos in my backend, both fully protected by JWT authentication and user validation.

If you want to see more of the code, including update and delete routes, or how everything is structured, feel free to check out the repository by clicking here.

Testing and Learning

The biggest lesson was about organization. Keeping layers (models, routes, database) well separated makes maintenance much easier. Using SQLModel saves time with data validation, but you have to pay attention to relationship details. JWT is powerful, but you need to be careful with the secret and expiration time. And above all, automatic documentation is a huge plus for anyone learning.

Did I make silly mistakes? Of course. I forgot to protect a route, left an endpoint open, and saved a password without hashing (just at the start, I swear!). But every mistake was a chance to learn. And in the end, seeing the app running, with authentication, CRUD, categories, and everything else, was incredibly satisfying.

And if you want to dive deeper or build your own backend, these were the main resources I used to learn and build mine:

I highly recommend checking them out if you want solid, practical guides to complement this article.

This Isn’t a How-To, It’s an Invitation

If you got this far expecting a step-by-step, you might be disappointed. But the idea of this article is to share my experience, to show that it’s possible to go from mobile to a functional, secure, modern backend. Don’t follow everything to the letter: adapt, experiment, make mistakes, learn. The code is here to inspire you, not to be copied line by line.

If you have any questions, want to adapt this for mobile, or just want to chat, reach out! :)

If you liked it, share it with other devs who want to learn backend without headaches!

--

--

Luiz Mello
Luiz Mello

Written by Luiz Mello

iOS Engineer, passionate about programming, technology, design and coffee! luizmello.dev (website and instagram)

Responses (1)