Si tu aplicación Node.js obtiene un token válido con el flujo client credentials pero la llamada a /_api/web/lists
de SharePoint responde con ID3035: The request was not valid or is malformed, el problema casi siempre está en un parámetro de autenticación mal alineado con el recurso al que llamas. En esta guía extensa aprenderás a:
- Entender por qué se genera el error ID3035.
- Solicitar y validar un token de aplicación correcto para SharePoint o Microsoft Graph.
- Configurar permisos en Entra ID (Azure AD) con seguridad de producción.
- Consumir listas desde SharePoint REST y desde Graph, con ejemplos completos en
node‑fetch
. - Aplicar un checklist de depuración que elimina el 90 % de los fallos de autenticación en menos de 5 minutos.
Comprender el error ID3035
ID3035 es un error genérico emitido por el STS de Entra ID cuando el scope o resource indicado en el token no coincide con el host que recibe la petición. El servidor detecta la discrepancia y descarta la solicitud sin llegar a validar permisos a nivel de lista o sitio.
Patrones de error más frecuentes
Síntoma | Causa raíz habitual |
---|---|
Mensaje “Audience missing or invalid” en el cuerpo JSON | El token se emitió para https://graph.microsoft.com y se usa contra mytenant.sharepoint.com , o viceversa. |
Código HTTP 401 + ID3035 | Falta de permiso Sites.Read.All o Sites.ReadWrite.All en la aplicación. |
HTTP 403 después de cambiar los scopes | Consentimiento de administrador aún no concedido o propagación de permisos incompleta (hasta 15 min). |
Flujo de autenticación Client‑Credentials paso a paso
En entorno servidor a servidor (S2S) el flujo client credentials delega las acciones en la service principal, sin intervención de usuario:
- Registro de la aplicación en Entra ID como “Web / API”.
- Concesión de permisos “
Sites.Read.All
” o “Sites.ReadWrite.All
” de tipo aplicación (application). - Consentimiento de administrador para todo el inquilino.
- Solicitud del token al endpoint
/oauth2/v2.0/token
conscope=https://{tenant}.sharepoint.com/.default
oscope=https://graph.microsoft.com/.default
. - Llamada a la API con encabezados de autenticación y formato de respuesta.
Tabla de acciones recomendadas
Paso | Acción recomendada | Motivo |
---|---|---|
Registrar la aplicación | Configura tipo Web/API, añade permisos Sites.Read.All o Sites.ReadWrite.All , concede consentimiento global. | El token emitido incluirá los claims adecuados para SharePoint o Graph. |
Solicitar el token | Usa el cuerpo:clientid=…&scope=https://{tenant}.sharepoint.com/.default&clientsecret=…&granttype=clientcredentials | El scope debe apuntar al mismo host que consumirás. |
Encabezados en la petición | Authorization: Bearer {token} Accept: application/json;odata=nometadata | Sin Accept , SharePoint devuelve XML o error de tipo de contenido. |
URL específica del sitio | Invoca https://mytenant.sharepoint.com/sites/SiteName/_api/web/lists | La raíz del inquilino puede estar bloqueada o usar Multi‑Geo. |
Migrar a Microsoft Graph | GET /v1.0/sites/{domain}:/sites/{siteName} Luego /lists | Un único token sirve para OneDrive, Teams y SharePoint; simplifica permisos. |
Ejemplo completo con node‑fetch
import fetch from 'node-fetch';
const tenantId = '00000000-1111-2222-3333-444444444444';
const clientId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const clientSecret = process.env.CLIENT_SECRET; // nunca hard‑codear
const siteUrl = 'https://mytenant.sharepoint.com/sites/ProjectX';
async function getToken () {
const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: clientId,
scope: `${siteUrl}/.default`,
client_secret: clientSecret,
granttype: 'clientcredentials'
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
const json = await res.json();
if (!res.ok) throw new Error(json.error_description);
return json.access_token;
}
async function fetchLists () {
const token = await getToken();
const apiUrl = `${siteUrl}/_api/web/lists?$select=Title,Id`;
const res = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json;odata=nometadata'
}
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
const data = await res.json();
console.log(data);
}
fetchLists().catch(console.error);
Uso de Microsoft Graph para mayor flexibilidad
Graph unifica identidades y permisos. Los pasos son equivalentes salvo por el scope y las rutas:
// 1. Token con scope graph
scope = 'https://graph.microsoft.com/.default'
// 2. Resolver el sitio:
GET https://graph.microsoft.com/v1.0/sites/mytenant.sharepoint.com:/sites/ProjectX
// 3. Listas del sitio:
GET https://graph.microsoft.com/v1.0/sites/{siteId}/lists
Ventajas de Graph:
- Mismo token para SharePoint, OneDrive y Teams.
- Permisos granulares (ej.
Sites.Selected
con sitios asignados). - Filtros OData,
$expand
de columnas, y tipos enriquecidos.
Checklist de depuración express
- ¿El
aud
del token coincide con el host? Verifícalo en jwt.ms. - ¿El permiso aparece en la sección
roles
? Si no, revisa el manifest. - ¿Estás usando el scope
/.default
correcto? Graph y SharePoint no son intercambiables. - ¿Se ha otorgado consentimiento de administrador tras añadir permisos? Compruébalo en Portal.
- ¿Ha pasado el tiempo de propagación (≤ 15 min)?
- ¿La URL apunta a la raíz
/sites/{site}
correcta? Confirma Multi‑Geo. - ¿Incluyes encabezados
Authorization
yAccept
? Sin ellos, SharePoint responde en XML y node‑fetch no lo parsea. - ¿El reloj del servidor está sincronizado (NTP)? Tokens con iat/nbf en el futuro se invalidan.
Buenas prácticas de seguridad
Para producción:
- Guarda
clientSecret
en Azure Key Vault o variables de entorno seguras. - Habilita Managed Identities si la app corre en Azure Functions, App Service o Container Apps.
- Sustituye secretos por certificados RSA‑2048; renueva cada seis meses.
- Limita el alcance con
Sites.Selected
y asigna sólo los sitios necesarios. - Activa Conditional Access con filtros de carga de trabajo.
¿Por qué “funciona en Postman” y falla en código?
Postman usa por defecto Accept: application/json;odata=verbose
y envía cookies de seguimiento. Además, su environment puede contener un token con permisos delegated emitido por login interactivo. En el servidor, el flujo client credentials no incluye las claims scp
, sólo roles
; de ahí el error si confundes tipos de permisos.
Preguntas frecuentes
¿Puedo usar un solo token para varios sitios?
Sí. Un token de aplicación emitido para el host de la colección raíz sirve para todos los sitios del mismo dominio. Con Graph, un token global sirve para cualquier sitio del tenant.
¿Cómo limito el token a un solo sitio?
Entra ID admite el permiso Sites.Selected
. Debes:
- Añadir
Sites.Selected
en la app. - Conceder consentimiento de administrador.
- Asignar el sitio concreto mediante
Set-PnPSiteAppPermission
o Graph/sites/{id}/permissions
.
¿Existe equivalente Graph para /_api/web/lists/getByTitle('DocLib')/items
?
Sí:GET /sites/{siteId}/lists/{listId}/items?expand=fields(select=Title,Id)
Conclusión
El error ID3035 suele resolverse alineando scope/resource, permisos y URL. Con el flujo client credentials tu service principal actúa como identidad corporativa; configurar correctamente Entra ID es el 80 % de la solución. El resto es enviar los encabezados adecuados y respetar la ruta del sitio. Si planeas extender tu integración a Teams o OneDrive, migra a Microsoft Graph y aprovecha un modelo de autenticación unificado.