REST API Setup (DRF)

April 12, 2026 · View on GitHub

This guide covers the DRF integration for SPAs, mobile apps, and headless APIs. Complete the Common Setup first.

1. Install DRF Extra

=== "DRF"

```bash
pip install django-passkeys[drf] drf-spectacular
```

=== "DRF + JWT"

```bash
pip install django-passkeys[drf-jwt] drf-spectacular
```

2. Configure

Add rest_framework to your apps and include the API URLs:

# settings.py
INSTALLED_APPS = [
    ...
    'rest_framework',
    'passkeys',
]
# urls.py
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

urlpatterns = [
    ...
    path('api/passkeys/', include('passkeys.api.urls')),
    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
    path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

]

Endpoints Overview

MethodURLAuthDescription
POSTregister/optionsRequiredGet WebAuthn registration options
POSTregister/verifyRequiredVerify and save new credential
POSTauthenticate/optionsPublicGet WebAuthn authentication options
POSTauthenticate/verifyPublicVerify assertion, return token
GET/RequiredList user's passkeys
GET<id>RequiredRetrieve a passkey
PATCH<id>RequiredUpdate a passkey (name, enabled)
DELETE<id>RequiredDelete a passkey

Example Code

!!! info - For an example for key management and registering new key , check mange.html - For a login example using the API, check login.html

Registration Flow

A User must be authenticated to register a new passkey. This is a two-step process: first get the WebAuthn challenge options from the server, then pass the browser's credential response back.

Quick implementation

This is a quick implementation for the registration flow, you can find all the details in the section below

  1. import the following provided js files in your page.

     <!-- Make sure that jQuery is added -->
     <script src="{% static 'passkeys/js/base64url.js'%}"></script>
     <script src="{% static 'passkeys/js/helpers.js'%}"></script>
     <script src="{% static 'passkeys/js/reg.js'%}"></script>
    
  2. Add The following js function to register passkey.

          function registerKey()
            {
                key_name = prompt("Enter key name");
                $.ajax({
                    "url": "/api/passkeys/register/options", dataType: "json", method: "POST",
                    headers: {'X-CSRFToken': "{{ csrf_token }}"},
                    success: async function(data) {
                        window.state_token = data.state_token
                        try {
                            key_info = await get_new_credentials(data.options)
                        }
                        catch(err) {
                            alert("Failed registering new key, you can try again.")
                            return
                        }
                        sendData=  {key_name: key_name, state_token: window.state_token, credential: key_info}
                        $.ajax({
                          url: "/api/passkeys/register/verify",
                          method: "POST",
                          headers: {'X-CSRFToken': "{{ csrf_token }}"},
                          data: JSON.stringify(sendData),
                           contentType: "application/json; charset=utf-8",
                          success: function(data){
                                alert("Key registered successfully");
                                showCredentials();
                            },
                            error:function(data) {
                                alert("Error registering key");
                            }
                            })
    
                    },
                    error: function(data) {
                        alert("Error registering key");
                    }
                })
            }
    

Detailed Implementation

Step 1 — Get registration options

Send a POST request to get the WebAuthn PublicKeyCredentialCreationOptions. The server returns the challenge, relying party info, user info, and a signed state_token that ties the two steps together.

POST /api/passkeys/register/options
Authorization: Bearer <your-auth-token>

Response 200 OK:

{
    "options": {
        "publicKey": {
            "rp": {
                "id": "example.com",
                "name": "My App"
            },
            "user": {
                "id": "am9obg==",
                "name": "john",
                "displayName": "John Doe"
            },
            "challenge": "dGVzdC1jaGFsbGVuZ2U...",
            "pubKeyCredParams": [
                {"type": "public-key", "alg": -7},
                {"type": "public-key", "alg": -257}
            ],
            "excludeCredentials": [],
            "authenticatorSelection": {
                "residentKey": "preferred",
                "userVerification": "preferred"
            }
        }
    },
    "state_token": "eyJhbGciOi..."
}
FieldDescription
options.publicKeyStandard WebAuthn PublicKeyCredentialCreationOptions — pass directly to navigator.credentials.create()
options.publicKey.challengeBase64URL-encoded challenge — must be decoded to ArrayBuffer before passing to the browser
options.publicKey.user.idBase64URL-encoded user ID — must be decoded to ArrayBuffer
options.publicKey.excludeCredentialsList of already-registered credential IDs — browser will skip these
state_tokenSigned server state — must be sent back in the verify step. Expires in 5 minutes

Step 2 — Create credential in the browser

Decode the binary fields and call the WebAuthn browser API:

// Decode binary fields from base64url to ArrayBuffer
const { options, state_token } = await response.json();
options.publicKey.challenge = base64url.decode(options.publicKey.challenge);
options.publicKey.user.id = base64url.decode(options.publicKey.user.id);
for (let cred of options.publicKey.excludeCredentials) {
    cred.id = base64url.decode(cred.id);
}

// Browser prompts user for biometric/PIN
const credential = await navigator.credentials.create(options);

// Convert the credential to a JSON-serializable object
// (credential.response contains ArrayBuffers that need base64url encoding)
const credentialJSON = {
    id: credential.id,
    rawId: base64url.encode(credential.rawId),
    response: {
        clientDataJSON: base64url.encode(credential.response.clientDataJSON),
        attestationObject: base64url.encode(credential.response.attestationObject),
    },
    type: credential.type,
};

!!! note "base64url helper" You need a base64url encode/decode utility. The library ships one at passkeys/static/passkeys/js/base64url.js, or use any npm package like base64url or @hexagon/base64.

Step 3 — Verify and save the credential

Send the credential and state token back to the server:

POST /api/passkeys/register/verify
Authorization: Bearer <your-auth-token>
Content-Type: application/json

Request body:

{
    "state_token": "eyJhbGciOi...",
    "key_name": "My Laptop",
    "credential": {
        "id": "credentialIdBase64url...",
        "rawId": "credentialIdBase64url...",
        "response": {
            "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3Jl...",
            "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10..."
        },
        "type": "public-key"
    }
}
FieldRequiredDescription
state_tokenYesThe token from the options step
key_nameNoHuman-readable name (e.g. "Work Laptop"). Defaults to detected platform ("Apple", "Google", etc.)
credentialYesThe WebAuthn credential object from navigator.credentials.create()
credential.idYesBase64URL-encoded credential ID
credential.response.clientDataJSONYesBase64URL-encoded client data
credential.response.attestationObjectYesBase64URL-encoded attestation

Response 201 Created:

{
    "id": 1,
    "name": "My Laptop",
    "enabled": true,
    "platform": "Apple",
    "added_on": "2026-03-24T12:00:00Z",
    "last_used": null
}

Error responses:

StatusCause
400Invalid or expired state token, verification failed, or passkey already registered
401/403Not authenticated

Authentication Flow

No login required — this is how users log in with a passkey. Two-step process similar to registration.

!!! info "Two Authentication Modes" Discoverable (passwordless) — omit username or send {}. The browser/OS shows all passkeys the user has saved for this domain and lets them pick. This is the true passwordless flow.

**Username-assisted** — send `{"username": "john"}` to narrow the prompt to that user's registered passkeys. Useful when the user already typed their username in your login form.

Quick implementation

!!! warning if no credentials are found a call to a js function named no_credentials_found() is perform, make sure to implement this js function.

  1. import the following provided js files in your page.

     <!-- Make sure that jQuery is added -->
     <script src="{% static 'passkeys/js/base64url.js'%}"></script>
     <script src="{% static 'passkeys/js/helpers.js'%}"></script>
     <script src="{% static 'passkeys/js/login.js'%}"></script>
    
  2. Add The following js function to register passkey.

    async function authn() {
            $.ajax({
                "url": "/api/passkeys/authenticate/options", dataType: "json", method: "POST",
                data: {"csrfmiddlewaretoken": "{{ csrf_token }}"},
                success: async function (data) {
                    window.state_token = data.state_token
                    user_data = await get_credential(data.options, true)
                    if (user_data.type == "password") {
                        console.log(user_data)
                        alert("user is logging in with password, not passkey, forward to the ");
                        return;
                    } else {
                        sendData = {state_token: window.state_token, credential: user_data.credential},
                            $.ajax({
                                url: "/api/passkeys/authenticate/verify",
                                method: "POST",
                                headers: {'X-CSRFToken': "{{ csrf_token }}"},
                                data: JSON.stringify(sendData), dataType: "json",
                                contentType: "application/json; charset=utf-8",
                                success: function (data) {
                                    alert("Login successfully as " + data.username);
                                    window.location.reload();
                                }
                            })
                    }
    
                }
            })
        }
    

!!! warning

In this quick implementation, the user is allowed to login by username and password (immediate mediation API), to enforce passkeys, change the following line
```js
 user_data = await get_credential(data.options, true)
```
to

```js
 user_data = await get_credential(data.options, false)
```

Detailed Implementation

Step 1 — Get authentication options

POST /api/passkeys/authenticate/options
Content-Type: application/json

Request body (choose one):

{}
{"username": "john"}
FieldRequiredDescription
usernameNoIf provided, only that user's credentials are included in allowCredentials. If omitted, the browser shows all discoverable passkeys for this domain

Response 200 OK:

{
    "options": {
        "publicKey": {
            "rpId": "example.com",
            "challenge": "cmFuZG9tLWNoYWxsZW5nZQ...",
            "allowCredentials": [
                {
                    "id": "credentialIdBase64url...",
                    "type": "public-key"
                }
            ],
            "userVerification": "preferred"
        }
    },
    "state_token": "eyJhbGciOi..."
}
FieldDescription
options.publicKey.challengeBase64URL-encoded — decode to ArrayBuffer before passing to browser
options.publicKey.allowCredentialsCredentials the browser should look for. Empty if no username was provided (discoverable mode)
options.publicKey.rpIdRelying party ID — matches your FIDO_SERVER_ID
state_tokenSigned server state — send back in verify step. Expires in 5 minutes

Step 2 — Get assertion in the browser

const { options, state_token } = await response.json();

// Decode binary fields
options.publicKey.challenge = base64url.decode(options.publicKey.challenge);
for (let cred of options.publicKey.allowCredentials) {
    cred.id = base64url.decode(cred.id);
}

// Browser prompts user for biometric/PIN
const assertion = await navigator.credentials.get(options);

// Convert to JSON-serializable object
const assertionJSON = {
    id: assertion.id,
    rawId: base64url.encode(assertion.rawId),
    response: {
        authenticatorData: base64url.encode(assertion.response.authenticatorData),
        clientDataJSON: base64url.encode(assertion.response.clientDataJSON),
        signature: base64url.encode(assertion.response.signature),
        userHandle: assertion.response.userHandle
            ? base64url.encode(assertion.response.userHandle)
            : null,
    },
    type: assertion.type,
};

Step 3 — Verify and get token

POST /api/passkeys/authenticate/verify
Content-Type: application/json

Request body:

{
    "state_token": "eyJhbGciOi...",
    "credential": {
        "id": "credentialIdBase64url...",
        "rawId": "credentialIdBase64url...",
        "response": {
            "authenticatorData": "SZYN5YgOjGh0NBcP...",
            "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0...",
            "signature": "MEUCIQC...",
            "userHandle": "am9obg=="
        },
        "type": "public-key"
    }
}
FieldRequiredDescription
state_tokenYesThe token from the options step
credentialYesThe WebAuthn assertion from navigator.credentials.get()
credential.idYesBase64URL-encoded credential ID — used to look up the passkey
credential.response.authenticatorDataYesBase64URL-encoded authenticator data
credential.response.clientDataJSONYesBase64URL-encoded client data
credential.response.signatureYesBase64URL-encoded signature
credential.response.userHandleNoBase64URL-encoded user handle (may be null)

Response 200 OK — varies by token backend:

=== "JWT (SimpleJWT)"

```json
{
    "user_id": 1,
    "username": "john",
    "token_type": "jwt",
    "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

Use `access` token in subsequent requests: `Authorization: Bearer <access>`

Refresh with `POST /api/token/refresh/` when expired (standard SimpleJWT endpoint).

=== "DRF Token"

```json
{
    "user_id": 1,
    "username": "john",
    "token_type": "token",
    "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"
}
```

Use in subsequent requests: `Authorization: Token <token>`

=== "Session"

```json
{
    "user_id": 1,
    "username": "john",
    "token_type": "session"
}
```

The session cookie is set automatically. No token needed — just use cookies.

Error responses:

StatusCause
400Invalid or expired state token
401Passkey authentication failed (invalid signature)
404Credential ID not found or passkey is disabled


Passkey Management

All management endpoints require authentication.

List all passkeys

GET /api/passkeys/
Authorization: Bearer <token>

Response 200 OK:

[
    {
        "id": 1,
        "name": "My MacBook",
        "enabled": true,
        "platform": "Apple",
        "added_on": "2026-03-24T12:00:00Z",
        "last_used": "2026-03-25T09:30:00Z"
    },
    {
        "id": 2,
        "name": "Work PC",
        "enabled": true,
        "platform": "Microsoft",
        "added_on": "2026-03-20T08:00:00Z",
        "last_used": null
    }
]
FieldTypeDescription
idintegerPasskey ID
namestringUser-given name or auto-detected platform
enabledbooleanWhether this passkey can be used for login
platformstringDetected platform: Apple, Google, Microsoft, Chrome on Apple, or Key
added_ondatetimeWhen the passkey was registered
last_useddatetime/nullLast successful authentication with this passkey

Retrieve a passkey

GET /api/passkeys/1
Authorization: Bearer <token>

Response 200 OK: Same shape as a single item from the list.

Returns 404 if the passkey doesn't exist or isn't owned by the authenticated user.

Update a passkey

PATCH /api/passkeys/1
Authorization: Bearer <token>
Content-Type: application/json

Request body (all fields optional):

{"name": "Work Laptop", "enabled": false}
FieldTypeDescription
namestringNew display name
enabledbooleanfalse to disable (passkey won't work for login), true to re-enable

Response 200 OK:

{
    "id": 1,
    "name": "Work Laptop",
    "enabled": false,
    "platform": "Apple",
    "added_on": "2026-03-24T12:00:00Z",
    "last_used": "2026-03-25T09:30:00Z"
}

Delete a passkey

DELETE /api/passkeys/1
Authorization: Bearer <token>

Response 204 No Content on success.

Returns 404 if the passkey doesn't exist or isn't owned by the authenticated user.

!!! warning Deleting a passkey is permanent. If the user has only one passkey and no password, they may lose access to their account.


Token Backend

After successful passkey authentication, the API returns a token. The backend is auto-detected from your Django settings:

PriorityConditionToken typeResponse fields
1PASSKEYS_API_TOKEN_BACKEND setting is setCustomWhatever your callable returns
2rest_framework_simplejwt in INSTALLED_APPSjwtaccess, refresh
3rest_framework.authtoken in INSTALLED_APPStokentoken
4Fallbacksession(session cookie set)

All responses always include user_id, username, and token_type.

Custom Backend

If none of the built-in backends fit, create your own:

# myapp/auth.py
def my_token_backend(user, request):
    """
    Custom token backend.
    Must accept (user, request) and return a dict.
    The dict is merged into the authenticate/verify response.
    """
    token = create_my_custom_token(user)
    return {
        'token_type': 'custom',
        'token': token,
        'expires_in': 3600,
    }
# settings.py
PASSKEYS_API_TOKEN_BACKEND = 'myapp.auth.my_token_backend'

Response will be:

{
    "user_id": 1,
    "username": "john",
    "token_type": "custom",
    "token": "my-custom-token-value",
    "expires_in": 3600
}

How State Tokens Work

The API uses signed state tokens instead of sessions to carry FIDO2 state between the options and verify steps. This is what makes the API work for stateless clients.

PropertyDetail
What it containsSerialized FIDO2 challenge state from the fido2 library
SigningHMAC with your Django SECRET_KEY via django.core.signing
Expiry5 minutes after creation
StatelessWorks with JWT/Token auth — no server-side session needed
Session fallbackAlso written to session when available (backward compat with template flow)

!!! tip "Why not just use sessions?" Mobile apps and SPAs using JWT don't have Django sessions. The signed state token carries the same information in the request body, so the two-step WebAuthn flow works without any server-side state.


Error Handling

All error responses follow DRF's standard format:

{"detail": "...."}
StatusWhenExample
400 Bad RequestValidation error, expired/invalid state token{"detail": "Registration state has expired, please try again"}
401 UnauthorizedAuthentication failed during verify{"detail": "Passkey authentication failed"}
403 ForbiddenMissing auth token on protected endpoint{"detail": "Authentication credentials were not provided."}
404 Not FoundPasskey not found, disabled, or not owned by user{"detail": "Passkey not found or disabled"}