Para añadir Sign in with Apple a una app de Capacitor, instala @capgo/capacitor-social-login, habilita la capacidad de Sign in with Apple en tu App ID y en Xcode, y llama a SocialLogin.initialize({ apple: {} }), luego llama a SocialLogin.login({ provider: 'apple' }). En iOS, esto abre la hoja nativa de Apple y devuelve un token de identidad. Android y web usan el flujo OAuth web de Apple, que requiere un Services ID y una URL de redirección HTTPS.
Esta guía cubre la configuración de Apple Developer, el cliente code para iOS, Android y web, la verificación del token en tu backend y los errores más habituales. Está orientada a Capacitor 8 y Xcode 26, que App Store Connect exige para los uploads desde abril de 2026.
Lo que necesitas antes de empezar
| Requisito | Por qué |
|---|---|
| Membresía de pago del Apple Developer Program | Los App IDs, Services IDs y las claves de Sign in with Apple solo están disponibles para miembros |
| Un Mac con Xcode 26 | Necesario para el build y el upload de apps iOS en 2026 |
| Proyecto Capacitor 8 | El plugin v8 es para Capacitor 8 |
| Un dominio HTTPS bajo tu control | Solo para Android y web. Apple lo rechaza localhost y URLs de retorno en HTTP simple |
| Un endpoint de backend | Siempre para verificar tokens. En Android también recibe el callback de Apple |
Si aún no tienes certificados de firma, el generador de certificados iOS los crea sin complicaciones de Mac keychain.
Cómo funciona Sign in with Apple en cada plataforma
Apple solo ofrece un SDK nativo para sus plataformas, así que el flujo varía:
| Plataforma | Mecanismo | Client ID utilizado | Backend necesario para el login |
|---|---|---|---|
| iOS | Nativo ASAuthorizationAppleIDProvider Hoja |
Bundle ID de tu app | No |
| Android | OAuth web de Apple en una Chrome Custom Tab, Apple hace POST a tu servidor y el servidor redirige de vuelta a la app | ID de servicios | Sí |
| Web | Popup de JS de Apple | ID de servicios | No (pero verifica el token) |
El audience (aud) del identity token sigue al client ID. Los tokens de iOS incluyen tu bundle ID, mientras que los de Android y web incluyen el Services ID. Tu backend debe aceptar ambos.
Paso 1: Configura la cuenta de Apple Developer
Habilita la capability en tu App ID
- Abrir Certificates, Identifiers & Profiles y selecciona Identificadores.
- Abre el App ID que coincida con tu Capacitor
appId(por ejemplocom.example.app). Create it if it does not exist. - En Capacidades, marca Iniciar sesión con Apple y guarda. Déjalo como App ID principal a menos que agrupes varias apps.
Si cambias las capabilities de un App ID existente, regenera los provisioning profiles que lo usen. Los perfiles antiguos no incluirán el nuevo entitlement.
Crea un Services ID (solo para Android y web)
- En Identificadores, haz clic en + y elige IDs de Servicios.
- Usa una descripción como “Example Web Login” y un identificador como
com.example.app.signin. Este valor será elclientIden Android y web. - Abre el nuevo Services ID y marca Iniciar sesión con Apple, y haz clic en Configurar.
- Selecciona tu App ID como el App ID principal.
- Añade tu dominio en Dominios y Subdominios (sin esquema, por ejemplo
auth.example.com). - Añade las URLs de retorno en URLs de retorno con el completo
https://ruta completa, por ejemplohttps://auth.example.com/apple/callback. Para logins web, añade la URL de la página que llama al login, con y sin la barra final.
Crea una clave de Sign in with Apple (intercambio de tokens para Android y servidor)
- Ve a Claves, haz clic en +, marca Iniciar sesión con Apple, y haz clic en Configurar para elegir el App ID principal.
- Registra la clave y descarga el
.p8archivo. Apple solo permite descargarlo una vez. - Anota el ID de clave y tu ID de equipo (que aparece en la parte superior derecha del portal de desarrolladores y en Membership).
Tu servidor usa la clave para firmar el client_secret JWT que envía al endpoint de tokens de Apple.
Registra fuentes de email si envías correos
Los usuarios pueden elegir Ocultar mi correo, lo que te proporciona una @privaterelay.appleid.com dirección. Apple solo reenviará correos de dominios y direcciones que registres en Servicios > Sign in with Apple para comunicación por email. Omítelo y tus correos de restablecimiento de contraseña y de recibos desaparecerán sin dejar rastro.
Paso 2: Instalar el plugin
bun add @capgo/capacitor-social-login
bunx cap sync
El plugin incluye varios providers. Si solo usas Apple (y quizás Google), desactiva los demás en capacitor.config.ts así que sus SDKs no se incluyen:
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.app',
appName: 'Example',
webDir: 'dist',
plugins: {
SocialLogin: {
providers: {
apple: true,
google: false,
facebook: false,
twitter: false,
},
},
},
};
export default config;
Ejecuta bunx cap sync de nuevo tras cambiar este bloque. Un provider desactivado no estará disponible en runtime, así que no desactives uno que uses. Eliminar Facebook también quita el AD_ID advertencias de permisos que SDK activa en Google Play Console.
Paso 3: Configuración de iOS
- Ejecuta
bunx cap open ios. - Selecciona el app objetivo, abrir Signing & Capabilities, haz clic en + Capability, y añade Iniciar sesión con Apple.
- Asegúrate de que el bundle identifier coincida con el App ID que configuraste.
Xcode escribe com.apple.developer.applesignin en App.entitlements. Si haces el build en CI o con Build de Capgo, haz commit de ese archivo de entitlements; de lo contrario, el cloud build generará una app que fallará con ASAuthorizationError code 1000.
No Info.plist URL scheme o AppDelegate cambio es necesario para Apple en iOS.
Paso 4: Configuración de Android
Apple no tiene SDK en Android. El plugin abre la página de autorización de Apple en un Custom Tab con tu Services ID. Apple envía el resultado como un form POST a tu URL de retorno HTTPS. Tu servidor intercambia el code, y luego redirige a un deep link que vuelve a abrir la app con los tokens.
Añadir el deep link
En android/app/src/main/AndroidManifest.xml, dentro del MainActivity <activity> elemento:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.app" android:host="apple-login" />
</intent-filter>
Reenvía el intent al plugin
Editar MainActivity.java para que el deep link llegue al plugin:
package com.example.app;
import android.content.Intent;
import android.net.Uri;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginHandle;
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
public class MainActivity extends BridgeActivity {
@Override
protected void onNewIntent(Intent intent) {
Uri data = intent.getData();
if (Intent.ACTION_VIEW.equals(intent.getAction()) && data != null
&& "apple-login".equals(data.getHost())) {
PluginHandle handle = getBridge().getPlugin("SocialLogin");
if (handle != null) {
Plugin plugin = handle.getInstance();
if (plugin instanceof SocialLoginPlugin) {
((SocialLoginPlugin) plugin).handleAppleLoginIntent(intent);
return;
}
}
}
super.onNewIntent(intent);
}
}
Filtrar por el host permite que tus otros deep links (reset de contraseña, links de marketing) sigan funcionando.
El endpoint de callback
Apple envía code, id_token, state, y en el primer login un user string JSON a tu URL de retorno. El endpoint debe intercambiar el code y redirigir a la app con success=true además de los tokens, que es el contrato que parsea el plugin. Un ejemplo mínimo con Hono usando jose:
import { Hono } from 'hono';
import { SignJWT, importPKCS8, createRemoteJWKSet, jwtVerify } from 'jose';
import { saveAppleRefreshToken } from './db'; // your own persistence layer
const app = new Hono();
const TEAM_ID = process.env.APPLE_TEAM_ID!;
const KEY_ID = process.env.APPLE_KEY_ID!;
const SERVICE_ID = process.env.APPLE_SERVICE_ID!; // com.example.app.signin
const PRIVATE_KEY = process.env.APPLE_PRIVATE_KEY!; // contents of the .p8 file
const APP_REDIRECT = 'com.example.app://apple-login';
const APPLE_JWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
async function appleClientSecret() {
const key = await importPKCS8(PRIVATE_KEY, 'ES256');
return new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: KEY_ID })
.setIssuer(TEAM_ID)
.setSubject(SERVICE_ID)
.setAudience('https://appleid.apple.com')
.setIssuedAt()
.setExpirationTime('5m')
.sign(key);
}
app.post('/apple/callback', async (c) => {
const form = await c.req.parseBody();
const code = String(form.code ?? '');
if (!code) {
return c.redirect(`${APP_REDIRECT}?success=false`);
}
// `form.user` is only present on the first authorization. Store it now.
const res = await fetch('https://appleid.apple.com/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: SERVICE_ID,
client_secret: await appleClientSecret(),
code,
grant_type: 'authorization_code',
redirect_uri: 'https://auth.example.com/apple/callback',
}),
});
if (!res.ok) {
return c.redirect(`${APP_REDIRECT}?success=false`);
}
const tokens = await res.json();
// Verify the identity token, then keep the long-lived refresh token on the server.
// Only the short-lived tokens go back to the app in the deep link.
const { payload } = await jwtVerify(tokens.id_token, APPLE_JWKS, {
issuer: 'https://appleid.apple.com',
audience: SERVICE_ID,
});
if (tokens.refresh_token) {
await saveAppleRefreshToken(String(payload.sub), tokens.refresh_token);
}
const params = new URLSearchParams({
success: 'true',
access_token: tokens.access_token,
id_token: tokens.id_token,
});
return c.redirect(`${APP_REDIRECT}?${params}`);
});
export default app;
El plugin lee los tokens desde el query string del deep link, por lo que cualquier dato ahí podría terminar en los logs o ser leído por otra app que registre el mismo custom scheme. Envía solo el token de corta duración access_token y id_token, nunca el refresh token, verifica la id_token signature and nonce en tu servidor antes de confiar en él, y usa un scheme único para tu app (tu reverse bundle ID).
El client_secret Un JWT puede durar hasta seis meses, pero generar uno corto por cada request evita caídas por expiración. Mantén el .p8 archivo en tu secret manager, nunca en el app bundle.
Paso 5: Configuración web
El build web usa Apple JS con el Services ID. Añade la URL exacta de la página que dispara el login a las return URLs del Services ID. Apple no acepta localhost, así que prueba el login web en un dominio de preview desplegado o mediante un túnel con un hostname real.
Paso 6: Inicializar e iniciar sesión
Inicializa una vez al arrancar la app. Pasa el redirectUrl solo en Android y web. En iOS, un valor no vacío redirectUrl hace que el plugin haga un POST del resultado a esa URL, algo que no quieres en el flujo nativo.
import { Capacitor } from '@capacitor/core';
import { SocialLogin } from '@capgo/capacitor-social-login';
const platform = Capacitor.getPlatform();
export async function initAuth() {
await SocialLogin.initialize({
apple: {
clientId: platform === 'ios' ? 'com.example.app' : 'com.example.app.signin',
redirectUrl:
platform === 'android'
? 'https://auth.example.com/apple/callback'
: platform === 'web'
? 'https://app.example.com/login'
: '',
},
});
}
Luego activa el login desde un botón. Usa un nonce aleatorio para que tu servidor pueda rechazar tokens replicados:
import { SocialLogin } from '@capgo/capacitor-social-login';
export async function signInWithApple() {
const nonce = crypto.randomUUID();
try {
const { result } = await SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
nonce,
},
});
if (!result.idToken) {
throw new Error('Apple did not return an identity token');
}
// Send to your backend. Name and email are only present on the first login.
const response = await fetch('https://api.example.com/auth/apple', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idToken: result.idToken,
nonce,
givenName: result.profile.givenName,
familyName: result.profile.familyName,
}),
});
return response.json();
} catch (error: any) {
if (error?.code === 'USER_CANCELLED') {
return null; // user closed the sheet, not a failure
}
throw error;
}
}
La respuesta te devuelve:
| Campo | Contenido |
|---|---|
result.idToken |
El JWT firmado por Apple, que es lo que verifica tu servidor |
result.profile.user |
El ID de usuario estable de Apple (igual que el sub claim) |
result.profile.email |
Email o dirección de relay privada, si se concede |
result.profile.givenName / familyName |
Solo en la primera autorización |
result.accessToken |
Con la configuración por defecto, esto contiene el code de autorización, no un token de acceso real |
result.authorizationCode |
Presente al inicializar con useProperTokenExchange: true |
Si tu servidor necesita refresh tokens de Apple (por ejemplo, para llamar al endpoint de revocación cuando un usuario borra su cuenta), configura useProperTokenExchange: true en la apple config y haz el exchange authorizationCode en el servidor.
Paso 7: Verifica el identity token en tu backend
Nunca confíes en los campos del perfil que envía el cliente. Verifica la firma del JWT con las claves públicas de Apple y revisa los claims:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const appleKeys = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
export async function verifyAppleToken(idToken: string, expectedNonce: string) {
const { payload } = await jwtVerify(idToken, appleKeys, {
issuer: 'https://appleid.apple.com',
audience: ['com.example.app', 'com.example.app.signin'], // bundle ID + Services ID
});
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch');
}
return {
appleUserId: payload.sub as string,
email: payload.email as string | undefined,
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
};
}
Usa como clave del registro de usuario sub, no el email. El email puede ser una dirección de relay y los usuarios pueden dejar de compartirlo. Tras la verificación, emite tu propio token de sesión. Si usas Supabase o Firebase, ellos verifican el token por ti; consulta Supabase con Capacitor Social Login.
Reglas de la App Store a tener en cuenta
- Directriz 4.8: si ofreces Google, Facebook u otro login de terceros como sistema principal, debes ofrecer una opción equivalente centrada en la privacidad. Sign in with Apple cumple con esto.
- Directriz 5.1.1(v): las apps que permiten crear cuentas deben permitir borrarlas desde la app. Para usuarios de Sign in with Apple, llama al
https://appleid.apple.com/auth/revokeendpoint al eliminarlo. Consulta nuestra guía de eliminación de cuentas. - Diseño del botón: usa los estilos de botón aprobados por Apple y asegúrate de que el botón sea al menos tan prominente como los demás botones de login.
Solución de problemas
| Síntoma | Causa probable | Solución |
|---|---|---|
ASAuthorizationError code 1000 en iOS |
Falta la capability o el entitlement en el target compilado | Añade Sign in with Apple en Xcode y haz commit App.entitlements, regenera los profiles |
| Code 1001 en iOS | El usuario canceló | El plugin rechaza con USER_CANCELLED, no lo trates como error |
invalid_client en Android o web |
Incorrecto clientId (se usó el bundle ID en vez del Services ID) o incorrecto client_secret |
Usa el Services ID, revisa el Team ID, Key ID y el contenido de la key |
invalid_request: Invalid redirect_uri |
La URL de retorno no coincide exactamente | Añade la URL completa https:// al Services ID; verifica las barras finales |
| La app no regresa tras el login en Android | El scheme o host del Intent filter no coincide con el redirect del servidor | Comparar com.example.app://apple-login carácter por carácter |
apple.android.redirectUrl is null or empty |
No redirectUrl en Android |
Pasa tu URL de callback HTTPS al inicializar en Android |
El nombre y el email son null |
No es la primera autorización | Léelos de tu base de datos. Revoca la app en los ajustes del dispositivo para probar de nuevo |
Token rechazado por el backend: aud no coincide |
El servidor solo acepta una audiencia | Acepta tanto el bundle ID como el Services ID |
Lanzar fixes después del release
La mayoría de los bugs de auth están en TypeScript: un client ID incorrecto por plataforma, un nonce faltante o un mal manejo de errores. Eso se puede enviar a los usuarios el mismo día con actualizaciones Live Update de Capgo sin esperar a la App Review. Los cambios en entitlements, el Android manifest o MainActivity son nativos y requieren un nuevo build para la store.
Próximos pasos
- Añade Google junto a Apple: Cómo iniciar sesión con Google usando Capacitor.
- Elige la combinación de métodos adecuada para tu app: Cómo añadir autenticación a una app Capacitor.
- Referencia de plugins y páginas de plataforma: Docs de Social Login y la Página de configuración de Apple iOS.