Sitemap
Ali Alachkar

Ali Alachkar

Senior Software Engineer | Security | Low Level | Python | Go | Rust

How to Add MacBook Touch ID Login to Django Using Passkeys and WebAuthn

13 min readAug 11, 2026

--

Press enter or click to view image in full size

If you’re building a Django application and you have a MacBook with Touch ID, you might wonder:

Can I let users log in to my Django application using their fingerprint?

The answer is yes.

But there is an important detail:

Django does not directly communicate with the MacBook’s fingerprint sensor.

Instead, we use a web standard called WebAuthn.

The browser talks to macOS.

macOS uses Touch ID to verify the user.

The browser then gives Django a cryptographic proof.

Django verifies that proof and logs the user in.

This is the technology behind modern Passkeys.

In this tutorial, we’re going to build a simple Django application that supports:

  • MacBook Touch ID
  • Passkeys
  • WebAuthn
  • Passwordless authentication
  • Django sessions
  • PostgreSQL/SQLite
  • Multiple passkeys per user
  • Discoverable credentials
  • Modern browser WebAuthn APIs

The goal is to understand what’s actually happening rather than simply copying a library and hoping it works.

First: What are we actually building?

Imagine that our login page contains:

┌─────────────────────────────────┐
│ │
│ Welcome back │
│ │
│ [ Sign in with Passkey ] │
│ │
└─────────────────────────────────┘

The user clicks:

Sign in with Passkey

Their MacBook asks for Touch ID:

┌─────────────────────────────┐
│ │
│ Touch ID │
│ │
│ Place your finger │
│ on Touch ID │
│ │
└─────────────────────────────┘

The user touches the sensor.

The browser receives a cryptographic assertion.

Django verifies it.

The user is logged in.

There is no password involved.

But where does Touch ID come into this?

This is the most important concept.

Our Django application never does this:

touch_id.authenticate()

There is no normal Django API like that.

Instead, we have:

                   Your Django server
│
│ WebAuthn
│ challenge
▼
Web Browser
│
│ WebAuthn API
▼
macOS Authenticator
│
│
Touch ID
│
▼
Cryptographic
response
│
▼
Web Browser
│
│ assertion
▼
Django server
│
▼
Verify signature
│
▼
login()

Touch ID is therefore the user-verification mechanism.

WebAuthn is the authentication protocol.

Django is the server that verifies the cryptographic proof.

This distinction is extremely important.

What is a Passkey?

You will hear three terms:

WebAuthn
Passkey
Touch ID

They are related, but they are not the same thing.

WebAuthn

WebAuthn is the web authentication standard.

It allows a website to use public-key cryptography for authentication.

Passkey

A passkey is a user-friendly name for a discoverable WebAuthn credential.

Passkeys can be stored by platform authenticators, password managers, or hardware authenticators.

Touch ID

Touch ID is the biometric verification mechanism on your Mac.

So, in our example:

Passkey
↓
WebAuthn
↓
MacBook platform authenticator
↓
Touch ID

The current WebAuthn ecosystem supports platform authenticators such as Touch ID, Face ID, Windows Hello, Android biometrics, and hardware security keys. The current Python webauthn package explicitly supports these authenticators.

The most important security concept

Let’s say the user registers their MacBook.

A cryptographic key pair is created:

Private key
Public key

The private key is protected by the authenticator.

The public key is sent to your Django server.

Your database stores the public key.

For example:

Django database
credential_id
public_key
sign_count
user_id

The private key is not stored in your Django database.

And your Django server never receives:

❌ Fingerprint
❌ Fingerprint image
❌ Fingerprint template
❌ Touch ID data
❌ MacBook password

The biometric verification happens locally.

The server only gets cryptographic proof.

What happens when the user logs in?

Let’s make it extremely simple.

Django creates a random challenge:

challenge = "random-value-123..."

The browser sends that challenge to the MacBook’s authenticator.

The authenticator asks the user to verify themselves.

The user touches Touch ID.

The authenticator signs the challenge.

Django receives:

challenge
+
authenticator data
+
signature

Django has the public key stored during registration.

It verifies the signature.

If the signature is valid:

login(request, user)

The user is authenticated.

Let’s build it

We’ll use:

Python
Django
webauthn
JavaScript
SQLite/PostgreSQL
MacBook with Touch ID

For the server-side WebAuthn implementation, we’ll use the Python webauthn package.

The current documentation identifies it as py_webauthn and currently lists version 2.2.0. It requires Python 3.10+.

Step 1 — Create the Django project

Create a virtual environment:

python -m venv .venv

Activate it:

source .venv/bin/activate

Install Django and WebAuthn:

pip install django webauthn

Create the project:

django-admin startproject config .

Create an authentication application:

python manage.py startapp authentication

Your project should now look like:

my-project/
│
├── config/
│ ├── settings.py
│ ├── urls.py
│ └── ...
│
├── authentication/
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ └── ...
│
└── manage.py

Add the application:

# config/settings.py

INSTALLED_APPS = [
# ...
"authentication",
]

Step 2 — Configure WebAuthn

We need to tell WebAuthn which website is allowed to use our credentials.

For local development:

# config/settings.py

WEBAUTHN_RP_ID = "localhost"
WEBAUTHN_RP_NAME = "My Django Application"
WEBAUTHN_ORIGIN = "http://localhost:8000"

For production:

WEBAUTHN_RP_ID = "example.com"
WEBAUTHN_RP_NAME = "My Django Application"
WEBAUTHN_ORIGIN = "https://example.com"

These values are security-sensitive.

Don’t take them from the HTTP request.

For example, don’t do this:

expected_origin = request.headers["Origin"]

The server should already know its expected origin.

Step 3 — Create the credential model

A user can have more than one passkey.

For example:

Ali
│
├── MacBook Pro
│ └── Touch ID
│
├── iPhone
│ └── Face ID
│
└── YubiKey
└── Security key

Therefore, don’t add a single touch_id_key field to the User model.

Create a separate table.

# authentication/models.py

from django.conf import settings
from django.db import models

class WebAuthnCredential(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="passkeys",
)
credential_id = models.BinaryField(
unique=True,
)
public_key = models.BinaryField()
sign_count = models.PositiveIntegerField(
default=0,
)
device_name = models.CharField(
max_length=255,
blank=True,
)
created_at = models.DateTimeField(
auto_now_add=True,
)
last_used_at = models.DateTimeField(
null=True,
blank=True,
)
def __str__(self):
return (
f"{self.user} - "
f"{self.device_name}"
)

Run migrations:

python manage.py makemigrations
python manage.py migrate

What are we storing?

The table might look like:

WebAuthnCredential


id
────────────────────────────
42
user_id
────────────────────────────
17
credential_id
────────────────────────────
(binary data)
public_key
────────────────────────────
(binary data)
sign_count
────────────────────────────
5
device_name
────────────────────────────
MacBook Pro Touch ID

Again:

There is no fingerprint in this table.

Step 4 — Understand registration

Before someone can log in with a passkey, they need to register one.

The registration flow is:

User is already logged in
↓
Click "Add Passkey"
↓
Django creates challenge
↓
Browser calls WebAuthn
↓
MacBook asks for Touch ID
↓
User touches Touch ID
↓
Credential created
↓
Browser sends credential to Django
↓
Django verifies it
↓
Django stores public key

This is called the registration ceremony.

Step 5 — Generate registration options

Create:

# authentication/views.py

import json
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from webauthn import generate_registration_options
from webauthn.helpers.options import options_to_json
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)

Now:

@login_required
@require_GET
def register_options(request):
user = request.user

options = generate_registration_options(
rp_id=settings.WEBAUTHN_RP_ID,
rp_name=settings.WEBAUTHN_RP_NAME,
user_id=str(user.pk).encode(),
user_name=user.get_username(),
user_display_name=(
user.get_full_name()
or user.get_username()
),
authenticator_selection=(
AuthenticatorSelectionCriteria(
resident_key=(
ResidentKeyRequirement.REQUIRED
),
user_verification=(
UserVerificationRequirement.REQUIRED
),
)
),
)
request.session[
"webauthn_registration_challenge"
] = options.challenge.hex()
return JsonResponse(
json.loads(
options_to_json(options)
)
)

There are two important settings here:

resident_key=ResidentKeyRequirement.REQUIRED

and:

user_verification=UserVerificationRequirement.REQUIRED

The first tells WebAuthn that we want a discoverable credential, which is important for the modern passkey experience.

The second says that user verification is required.

For a MacBook, that can result in Touch ID being used.

Discoverable credentials are the modern terminology that replaced the older “resident key” terminology. W3C/FIDO documentation describes passkeys in terms of discoverable credentials.

Step 6 — Create the browser UI

Let’s make a very simple page:

<!DOCTYPE html>
<html>
<head>
<title>Passkey Demo</title>
</head>

<body>
<h1>Security Settings</h1>
<button id="register-passkey">
Add Passkey
</button>
<script>
// JavaScript goes here
</script>
</body>
</html>

When the user clicks:

Add Passkey

we call the WebAuthn API.

Step 7 — Use the modern WebAuthn JSON API

Modern browsers provide JSON serialization helpers for WebAuthn credentials and options.

This is much cleaner than manually converting every ArrayBuffer to Base64URL.

For example:

const credential =
await navigator.credentials.create({
publicKey: options,
});

await fetch(
"/auth/webauthn/register/verify/",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(
credential.toJSON()
),
}
);

Modern WebAuthn also provides APIs such as PublicKeyCredential.getClientCapabilities() and isConditionalMediationAvailable(). MDN's current WebAuthn documentation reflects these newer capabilities.

Step 8 — Register the passkey

Our complete registration JavaScript becomes:

document
.getElementById("register-passkey")
.addEventListener("click", async () => {

try {
const response = await fetch(
"/auth/webauthn/register/options/"
);
if (!response.ok) {
throw new Error(
"Unable to get registration options"
);
}
const options =
await response.json();
/*
* The current WebAuthn JSON API allows
* browsers to convert JSON options into
* the structure expected by WebAuthn.
*/
const publicKey =
PublicKeyCredential
.parseCreationOptionsFromJSON(
options
);
const credential =
await navigator.credentials.create({
publicKey,
});
await fetch(
"/auth/webauthn/register/verify/",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(
credential.toJSON()
),
}
);
alert(
"Passkey registered successfully!"
);
} catch (error) {
console.error(error);
alert(
"Unable to register passkey."
);
}
});

This is a much more modern browser implementation than manually converting every WebAuthn binary property yourself.

Step 9 — Verify registration on Django

Now Django receives the credential.

import json
from webauthn import (
verify_registration_response,
)
from .models import WebAuthnCredential

@login_required
@require_POST
def register_verify(request):
credential = json.loads(
request.body
)
challenge_hex = request.session.pop(
"webauthn_registration_challenge",
None,
)
if not challenge_hex:
return JsonResponse(
{
"error":
"Registration challenge expired."
},
status=400,
)
verification = (
verify_registration_response(
credential=credential,
expected_challenge=bytes.fromhex(
challenge_hex
),
expected_rp_id=(
settings.WEBAUTHN_RP_ID
),
expected_origin=(
settings.WEBAUTHN_ORIGIN
),
)
)
WebAuthnCredential.objects.create(
user=request.user,
credential_id=(
verification.credential_id
),
public_key=(
verification.credential_public_key
),
sign_count=(
verification.sign_count
),
device_name=(
"MacBook Touch ID"
),
)
return JsonResponse(
{
"success": True
}
)

The important part is:

verify_registration_response()

We don’t trust the browser.

We verify the registration response cryptographically.

Step 10 — Now let’s implement login

Registration is done.

The user now has a passkey.

Our login page can simply contain:

<button id="login-passkey">
Sign in with Passkey
</button>

The modern experience should ideally not require:

Username
Password

before the passkey.

The user simply says:

Sign in with Passkey

and the browser discovers the appropriate credential.

Step 11 — Generate authentication options

Create:

from webauthn import (
generate_authentication_options,
)

from webauthn.helpers.structs import (
UserVerificationRequirement,
)

Then:

@require_GET
def login_options(request):
options = (
generate_authentication_options(
rp_id=settings.WEBAUTHN_RP_ID,
user_verification=(
UserVerificationRequirement.REQUIRED
),
)
)
request.session[
"webauthn_authentication_challenge"
] = options.challenge.hex()
return JsonResponse(
json.loads(
options_to_json(options)
)
)

Notice something important.

We aren’t providing:

allow_credentials=[...]

This is intentional.

Get Ali Alachkar’s stories in your inbox

Join Medium for free to get updates from this writer.

We’re allowing the browser/authenticator to discover a suitable passkey.

That’s the modern passwordless/passkey experience.

Step 12 — Call WebAuthn from the browser

document
.getElementById("login-passkey")
.addEventListener("click", async () => {
try {
const response = await fetch(
"/auth/webauthn/login/options/"
);
const options =
await response.json();
const publicKey =
PublicKeyCredential
.parseRequestOptionsFromJSON(
options
);
const credential =
await navigator.credentials.get({
publicKey,
});
const verifyResponse =
await fetch(
"/auth/webauthn/login/verify/",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(
credential.toJSON()
),
}
);
if (!verifyResponse.ok) {
throw new Error(
"Authentication failed"
);
}
window.location.href = "/";
} catch (error) {
console.error(error);
alert(
"Passkey authentication failed."
);
}
});

Now the interesting thing happens:

navigator.credentials.get()

The browser asks the platform authenticator for a credential.

On your MacBook, macOS can display the Touch ID prompt.

You touch the sensor.

The browser receives the signed assertion.

Step 13 — Verify the authentication

Now Django receives:

credential.id
credential.response

We first find the credential in our database.

from webauthn.helpers import (
base64url_to_bytes,
)

Then:

from django.contrib.auth import login

@require_POST
def login_verify(request):

credential = json.loads(
request.body
)
credential_id = base64url_to_bytes(
credential["id"]
)
stored_credential = (
WebAuthnCredential.objects
.select_related("user")
.filter(
credential_id=credential_id
)
.first()
)
if not stored_credential:
return JsonResponse(
{
"error": "Unknown passkey."
},
status=401,
)
challenge_hex = request.session.pop(
"webauthn_authentication_challenge",
None,
)
if not challenge_hex:
return JsonResponse(
{
"error": "Authentication challenge expired."
},
status=400,
)
verification = (
verify_authentication_response(
credential=credential,
expected_challenge=bytes.fromhex(
challenge_hex
),
expected_rp_id=(
settings.WEBAUTHN_RP_ID
),
expected_origin=(
settings.WEBAUTHN_ORIGIN
),
credential_public_key=(
stored_credential.public_key
),
credential_current_sign_count=(
stored_credential.sign_count
),
require_user_verification=True,
)
)
stored_credential.sign_count = (
verification.new_sign_count
)
stored_credential.save(
update_fields=[
"sign_count",
]
)
login(
request,
stored_credential.user,
)
return JsonResponse(
{
"success": True
}
)

The most important line is:

verify_authentication_response()

This is where the server verifies that the credential actually produced a valid signature.

Step 14 — Add the URLs

# authentication/urls.py

from django.urls import path
from . import views

urlpatterns = [
path(
"webauthn/register/options/",
views.register_options,
),
path(
"webauthn/register/verify/",
views.register_verify,
),
path(
"webauthn/login/options/",
views.login_options,
),
path(
"webauthn/login/verify/",
views.login_verify,
),
]

And:

# config/urls.py

from django.urls import include, path

urlpatterns = [
path(
"auth/",
include(
"authentication.urls"
),
),
]

The complete login flow

Now let’s put everything together.

The user visits:

https://example.com/login/

They click:

Sign in with Passkey

Django creates:

Random challenge

The browser calls:

navigator.credentials.get()

macOS asks:

Touch ID

The user touches the sensor.

The authenticator creates a cryptographic assertion.

The browser sends it to Django.

Django finds:

credential_id
↓
public_key
↓
user

Django verifies:

challenge
origin
RP ID
signature
user verification
sign counter

Everything is valid.

Finally:

login(request, user)

And the user is authenticated.

What exactly does Touch ID verify?

A common misconception is:

“Django knows that my fingerprint matched.”

It doesn’t.

Django knows that:

“The authenticator associated with this credential successfully performed the required user-verification ceremony and produced a valid cryptographic signature.”

That is a much better security model.

The biometric information remains local to the device/authenticator.

What if someone steals the database?

Suppose an attacker gets your database.

They obtain:

credential_id
public_key
sign_count
user_id

But they don’t get:

private key
fingerprint
Touch ID data

The public key isn’t enough to impersonate the credential.

This is one of the major advantages of public-key authentication.

What if the user has multiple devices?

This is where Passkeys become particularly useful.

A user can have:

User
│
├── MacBook
│ └── Touch ID
│
├── iPhone
│ └── Face ID
│
├── iPad
│ └── Touch ID
│
└── Security Key
└── FIDO2

Your Django model supports this naturally:

class WebAuthnCredential(models.Model):
user = models.ForeignKey(...)
credential_id = models.BinaryField(...)
public_key = models.BinaryField(...)

One user → many credentials.

Add a passkey management page

A real application should allow users to see their registered passkeys.

For example:

Security

Passkeys
✓ MacBook Pro Touch ID
Added Aug 11, 2026
Last used: Today
✓ iPhone Face ID
Added Aug 10, 2026
Last used: Yesterday
[Add Passkey]

And users should be able to revoke credentials.

For example:

def delete_passkey(request, credential_id):
credential = get_object_or_404(
WebAuthnCredential,
credential_id=credential_id,
user=request.user,
)
credential.delete()
return redirect(
"security-settings"
)

In a real application, add CSRF protection, authorization checks and confirmation before deletion.

Modern passkey autofill

There is another nice WebAuthn feature called Conditional UI.

Instead of making the user click a button, the browser can offer passkeys through the normal login UI/autofill experience.

Modern browsers expose:

PublicKeyCredential.isConditionalMediationAvailable()

to detect whether conditional mediation is available.

Conceptually:

if (
await PublicKeyCredential
.isConditionalMediationAvailable()
) {

const credential =
await navigator.credentials.get({
publicKey,
mediation: "conditional",
});
}

This can produce a much nicer experience.

For example:

Email
┌──────────────────────────────┐
│ 👤 ali@example.com │
└──────────────────────────────┘

┌──────────────────────────────┐
│ Password │
└──────────────────────────────┘
OR
┌──────────────────────────────┐
│ Use your passkey │
└──────────────────────────────┘

The browser/OS handles the passkey selection.

Why don’t we just use Apple’s Touch ID API?

Because your application is a web application.

If you build:

Native macOS application

you can interact with Apple’s native APIs.

But if you have:

Django
+
React
+
Chrome/Safari

you shouldn’t build a Mac-specific authentication system.

WebAuthn gives you:

MacBook
Touch ID

iPhone
Face ID

Windows
Windows Hello

Android
Fingerprint

YubiKey
FIDO2

with the same server-side authentication protocol.

That is much more useful for a web application.

HTTPS is important

WebAuthn is designed to work with secure origins.

For production, use:

WEBAUTHN_ORIGIN = (
"https://example.com"
)

WEBAUTHN_RP_ID = (
"example.com"
)

Don’t build your production authentication system around:

http://example.com

Use HTTPS.

For local development, localhost is specially treated by browsers and is commonly used for WebAuthn development.

Don’t trust the browser

This is one of the most important rules.

Never do:

user = request.user

and assume the WebAuthn credential belongs to that user unless you’ve actually verified the credential.

Never trust:

origin
RP ID
challenge
credential ID

just because they arrived in JSON.

The server must validate them.

Challenges must be one-time values

Imagine Django generates:

challenge = ABC123

The user authenticates.

If we allow:

ABC123

to be used again, an attacker could potentially replay an old authentication response.

That’s why we remove the challenge:

challenge = request.session.pop(
"webauthn_authentication_challenge",
None,
)

After it is consumed, it is gone.

In production, you can also use a short expiration window.

Why do we store sign_count?

WebAuthn authenticators can provide a signature counter.

We store:

sign_count

After successful authentication:

stored_credential.sign_count = (
verification.new_sign_count
)

This allows the server to detect certain types of authenticator cloning or unexpected counter behavior.

The current Python WebAuthn library includes this as part of its authentication verification flow.

Production checklist

Before using this in a real application, I would add:

✓ HTTPS
✓ Secure cookies
✓ CSRF protection
✓ One-time challenges
✓ Challenge expiration
✓ Fixed RP ID
✓ Fixed expected origin
✓ User verification required
✓ Credential sign counter
✓ Multiple credentials per user
✓ Credential deletion/revocation
✓ Device names
✓ Last-used timestamp
✓ Account recovery
✓ Rate limiting
✓ Audit logging
✓ Session rotation
✓ Password/fallback recovery

For a company application, I would also log:

Passkey registered
Passkey authentication successful
Passkey authentication failed
Passkey revoked
Unknown credential
Invalid origin
Invalid RP ID

But don’t log sensitive credential material.

One more important point: recovery

Passkeys are great, but you still need to think about:

What happens if the user loses all their devices?

For example:

User
│
├── MacBook → lost
│
└── iPhone → lost

The application needs a secure account recovery mechanism.

Depending on your application, this could be:

Password fallback
Recovery codes
Admin-assisted recovery
Verified email recovery
Another registered security key

Don’t design a passwordless authentication system without designing the recovery flow.

The final architecture

After everything we’ve built, our architecture looks like this:

INTERNET
│
▼
┌─────────────────┐
│ Django │
│ │
│ WebAuthn API │
│ │
│ User │
│ Credential │
└────────┬────────┘
│
HTTPS / JSON
│
▼
┌─────────────────┐
│ Browser │
│ │
│ WebAuthn API │
└────────┬────────┘
│
▼
┌─────────────────┐
│ macOS │
│ │
│ Platform │
│ Authenticator │
└────────┬────────┘
│
▼
Touch ID
│
▼
Cryptographic
assertion
│
▼
┌─────────────────┐
│ Django │
│ │
│ Verify │
│ signature │
│ │
│ login(user) │
└─────────────────┘

What did we actually build?

We didn’t build:

"Django Touch ID"

We built:

Django
+
WebAuthn
+
Passkeys

with:

MacBook Touch ID

acting as the local authenticator.

That distinction is important because tomorrow the same Django application can authenticate someone using:

MacBook Touch ID

or:

iPhone Face ID

or:

Windows Hello

or:

Android fingerprint

or:

YubiKey

without changing the fundamental authentication protocol.

Final takeaway

If you’re a Django developer and your goal is:

“I want users to log into my website using the fingerprint sensor on their MacBook.”

Don’t try to access Touch ID directly from Django.

Use:

WebAuthn

and preferably build it around:

Passkeys
+
Discoverable credentials
+
User verification
+
Modern WebAuthn JSON APIs

The user’s fingerprint stays on the device.

Your server stores a public key.

The browser handles communication with the authenticator.

Django verifies the cryptographic proof.

And the final user experience becomes incredibly simple:

          Sign in             
↓
[ Passkey ]
↓
Touch ID
↓
Logged in

That is the key idea behind passwordless authentication on the modern web.

The WebAuthn standard itself is now at the Level 3 generation, with current work covering features such as conditional creation/get, JSON serialization, client capabilities, and credential-management signal APIs.

For Python/Django, the webauthn package currently documents support for Touch ID and other FIDO2 authenticators and provides the server-side registration and authentication primitives we used here.

--

--