POST
/
api
/
v1
/
auth
/
oauth
/
apple
Sign in with Apple
curl --request POST \
  --url https://api.example.com/api/v1/auth/oauth/apple
import requests

url = "https://api.example.com/api/v1/auth/oauth/apple"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/auth/oauth/apple', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/auth/oauth/apple",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/auth/oauth/apple"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/auth/oauth/apple")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/auth/oauth/apple")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

Signs a user in (or registers them on first use) with Sign in with Apple. The mobile app performs the native Apple authorization, exchanges the resulting Apple identity token for a Firebase credential, and POSTs the Firebase ID token here — the same request body and response shape as /oauth/google. The backend verifies the token with firebase-admin (signature, expiry, issuer, audience = our Firebase project) and additionally checks that the token’s firebase.sign_in_provider claim is apple.com. That pin matters: both providers mint Firebase ID tokens for the same project, so the signature check alone cannot tell them apart, and without it a Google token POSTed here would create an APPLE-bound account. There are three outcomes:
  • First-time Apple user → a new account is created with oauthProvider: 'APPLE'. The response is 201 Created with isNewUser: true. The client should route the user into onboarding.
  • Returning Apple user (same Apple identity) → 200 OK with isNewUser: false.
  • Email already owned by a non-Apple account409 ACCOUNT_EXISTS_VIA_OAUTH. Swappr does not silently attach Apple to a pre-existing email/password (or Google) account.
Private-relay emails. When the user chooses Hide My Email, Apple supplies a @privaterelay.appleid.com address instead of their real one. It is a real, deliverable address and is stored and treated exactly like any other — nothing downstream special-cases it. Note that the relay address is per-app, so the same person signing in with Google will have a different email and therefore a separate account.
Name is only ever sent once. Apple returns the user’s full name on the first authorization only, never on subsequent sign-ins. Swappr does not depend on it: OAuth accounts start with firstName / lastName as null and collect the name during onboarding, exactly as Google accounts do.

Authentication

None required. The Firebase ID token in the body is the credential.

Path parameters

None.

Query parameters

None.

Request body

FieldTypeRequiredAllowed valuesExample
idTokenstringyesA Firebase ID token (signed JWT) obtained by exchanging the Apple identity token. 1..8192 chars.eyJhbGciOiJSUzI1NiIsImtpZCI6...

Example payload

{
  "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2N..."
}

Response — 201 Created (new user) / 200 OK (returning user)

FieldTypeNotesExample
accessTokenstringShort-lived RS256 JWT (15 min). Send as Authorization: Bearer <token>.eyJhbGci...
refreshTokenstringOpaque 256-bit token. Exchange via /refresh.r8Kf...
isNewUserbooleantrue when this call created the account (HTTP 201), false for a returning user (HTTP 200).true
user.idstringThe user’s ID.usr_01HZQ7K3M4N5P6Q7R8S9T0V1W2
user.emailstringThe Apple email, lowercased. May be a private-relay address.zx9q7w@privaterelay.appleid.com
user.firstNamestring | nullnull until set during onboarding / profile edit.null
user.lastNamestring | nullnull until set.null
user.avatarUrlstring | nullnull until set.null
user.onboardingStepstringCurrent onboarding step; first step for a brand-new user.verify_tenancy
user.tenancyStatusstringTenancy verification status.not_submitted
user.subscriptionStatusstringSubscription state.free_launch

Example response — 201

{
  "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "r8Kf3n0pQv...",
  "isNewUser": true,
  "user": {
    "id": "usr_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
    "email": "zx9q7w@privaterelay.appleid.com",
    "firstName": null,
    "lastName": null,
    "avatarUrl": null,
    "onboardingStep": "verify_tenancy",
    "tenancyStatus": "not_submitted",
    "subscriptionStatus": "free_launch"
  }
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDidToken missing, blank, or longer than 8192 chars.
401OAUTH_INVALID_TOKENToken failed verification — malformed, expired, wrong signature, minted for a different Firebase project, carried no email, or its sign_in_provider was not apple.com.
403ACCOUNT_BANNEDThe matched account is banned.
409ACCOUNT_EXISTS_VIA_OAUTHThe email already belongs to a non-Apple account. Sign in with the original method, then link Apple.
503OAUTH_NOT_CONFIGUREDFirebase credentials (FCM_SERVICE_ACCOUNT_JSON + FIREBASE_PROJECT_ID) are not set in this environment.

Example error — 401

{
  "type": "https://api.swappr.co.uk/errors/oauth-invalid-token",
  "title": "OAuth token is invalid",
  "status": 401,
  "code": "OAUTH_INVALID_TOKEN",
  "detail": "Expected a apple.com token but received google.com",
  "instance": "/api/v1/auth/oauth/apple",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Side effects

  • First-time sign-in only: inserts a new row into the users collection with oauthProvider: 'APPLE', oauthId set to the Firebase uid, passwordHash: null, and emailVerified taken from the token.
  • Issues a new session: inserts a refresh_tokens row and signs an access JWT.
  • No email is sent (Apple has already verified the address).

Rate limiting

This endpoint shares the auth burst limiter (10 requests / minute / IP) with /login, on top of the router-level /auth/* cap.

Notes for the client

  1. The iOS app must have the Sign in with Apple capability on its App ID and the com.apple.developer.applesignin entitlement.
  2. Generate a random nonce, pass its SHA-256 hash to Apple, and hand the raw nonce to Firebase alongside the identity token. This binds the token to that one sign-in attempt so it cannot be replayed.
  3. Read the Firebase ID token (user.getIdToken()), not the Apple identity token, and POST it here.
  4. The button is iOS-only: Apple only requires it on Apple platforms, and the Android path would need a separate Services ID that Swappr does not configure.

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/auth/oauth/apple \
  -H "Content-Type: application/json" \
  -d '{
    "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2N..."
  }'