12k
All articles

Cómo añadir el inicio de sesión con GitHub

Añade el inicio de sesión con GitHub en Node y Express. Implementa OAuth con state y PKCE, intercambia códigos de forma segura, obtiene el correo verificado y renueva tokens.

OpenReplay Team
OpenReplay Team
Cómo añadir el inicio de sesión con GitHub

Para añadir el inicio de sesión con GitHub, rediriges al usuario a https://github.com/login/oauth/authorize, recibes un code temporal en tu URL de callback e intercambias ese código por un token de acceso mediante una petición POST a https://github.com/login/oauth/access_token desde tu servidor.

Tienes la configuración de la OAuth app abierta en una pestaña y tu route handler en otra, y la mayoría de los tutoriales delegan todo el trabajo en un SDK. Esta guía construye el flujo de servidor en Node usando fetch y Express, sin ninguna librería de OAuth. Para el modelo subyacente, consulta All about OAuth 2.0. Aquí se tratan state y PKCE, el problema del email nulo, los scopes y los cambios en las redirect URI y en el refresco de tokens que GitHub publicó el 14 de agosto de 2026.

Conclusiones clave

  • El código de autorización de GitHub caduca 10 minutos después de emitirse, y el intercambio del código debe ejecutarse en tu servidor porque requiere el client secret.
  • Genera state en el servidor, guárdalo en una cookie httpOnly y aborta el callback si el valor devuelto no coincide. Esto bloquea el login CSRF.
  • GET https://api.github.com/user devuelve email: null cuando el usuario no tiene un email público, así que solicita user:email y usa la entrada principal y verificada de GET /user/emails.
  • Desde la actualización de GitHub del 14 de agosto de 2026, una OAuth app puede registrar hasta 10 redirect URI, y las apps nuevas emiten por defecto tokens de acceso de 8 horas con refresh tokens de 6 meses.

Registrar la OAuth app de GitHub

Crea una OAuth app en Settings → Developer settings → OAuth Apps, copia el client ID y genera un client secret. La guía de registro de GitHub describe todos los campos.

Desde la versión del 14 de agosto de 2026, una sola OAuth app puede contener hasta 10 redirect URI, que se añaden con el botón Add callback URL. Así puedes cubrir producción, despliegues de preview y desarrollo local con una única app. Para el trabajo en local, registra http://127.0.0.1:3000/auth/github/callback. localhost funciona, pero las recomendaciones de GitHub sobre loopback señalan que el RFC de OAuth recomienda el literal 127.0.0.1.

La coincidencia con comodines (wildcard matching) es ahora una opción de cada redirect URI. Si tu app tenía una sola URL de callback antes del 3 de agosto de 2026, GitHub le ha activado el wildcard matching para conservar el comportamiento de redirección anterior. Desactívalo, salvo que controles todos los subdominios y rutas que cuelgan de esa URL.

Redirigir al endpoint de autorización de GitHub

El botón de inicio de sesión apunta a una ruta del servidor. Esa ruta genera state y un verifier de PKCE, guarda ambos en cookies de corta duración y redirige a GET https://github.com/login/oauth/authorize.

import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI } = process.env;
// GITHUB_CLIENT_SECRET is read only here, on the server. Never ship it to the browser.
const isProd = process.env.NODE_ENV === 'production';

const tempCookie = {
  httpOnly: true,
  secure: isProd,
  sameSite: 'lax',
  maxAge: 10 * 60 * 1000, // matches the 10-minute code lifetime
  path: '/auth/github',
};

app.get('/auth/github', (req, res) => {
  const state = crypto.randomBytes(32).toString('base64url');
  const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

  res.cookie('gh_state', state, tempCookie);
  res.cookie('gh_verifier', verifier, tempCookie);

  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: GITHUB_REDIRECT_URI, // one value per environment
    scope: 'read:user user:email',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

Los parámetros de autorización de GitHub admiten un único método PKCE, S256, y rechazan plain. El challenge debe ser un valor SHA-256 de 43 caracteres, que es exactamente lo que se obtiene al codificar en base64url un digest SHA-256. Si es habitual que tus usuarios tengan más de una cuenta de GitHub, añade prompt=select_account para que GitHub muestre siempre el selector de cuentas.

Validar state para frenar el login CSRF

El parámetro state es un valor aleatorio que nadie puede adivinar. Tu servidor lo crea y lo guarda en la sesión o en una cookie httpOnly. Cuando GitHub redirige de vuelta, compáralo con el state del callback. Si no coinciden, detén el inicio de sesión y no intercambies el código.

Sin esa comprobación, tu app queda expuesta al login CSRF, un ataque que inicia la sesión de la víctima en la cuenta del atacante:

  1. El atacante inicia tu flujo de inicio de sesión con GitHub usando su propia cuenta de GitHub.
  2. Aprueba el acceso, pero se detiene antes de que se ejecute el callback y se queda con su code válido.
  3. Consigue que el navegador de la víctima cargue https://yourapp.com/auth/github/callback?code=ATTACKER_CODE, por ejemplo mediante una imagen oculta o un enlace que se envía automáticamente.
  4. Tu servidor intercambia el código e inicia la sesión de la víctima como si fuera el atacante. Peor aún: si la víctima ya tenía la sesión iniciada, vincula la identidad de GitHub del atacante a la cuenta de la víctima.
  5. Todo lo que la víctima guarde a partir de entonces acaba en una cuenta que controla el atacante. Además, una identidad vinculada permite al atacante iniciar sesión más adelante como la víctima.

Con state, el paso 4 falla. El navegador de la víctima no tiene cookie gh_state o tiene una que no coincide con el valor del atacante, así que el servidor rechaza la petición. PKCE añade una segunda vinculación: el código solo funciona junto con el code_verifier almacenado en el navegador que inició el flujo. RFC 6749 define state con este fin, y el documento OAuth 2.0 Security Best Current Practice recomienda PKCE para todos los clientes. Las recomendaciones sobre state y PKCE de Securing OAuth best practices profundizan en el tema.

function statesMatch(received, stored) {
  if (typeof received !== 'string' || typeof stored !== 'string') return false;
  const a = Buffer.from(received);
  const b = Buffer.from(stored);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Los atributos de la cookie también importan. HttpOnly impide que los scripts accedan al valor y Secure lo restringe a HTTPS. SameSite=Lax permite que la cookie sobreviva a la redirección de nivel superior con la que GitHub vuelve a tu app. Con una única cookie gh_state, una segunda pestaña de inicio de sesión sobrescribe la primera, así que muestra un mensaje claro para reintentar en lugar de un error en blanco.

Gestionar el callback e intercambiar el código

El callback comprueba state, elimina las cookies temporales y envía el código mediante POST a https://github.com/login/oauth/access_token con Accept: application/json. Sin esa cabecera, el endpoint de tokens de GitHub responde con un cuerpo codificado como formulario. El código caduca 10 minutos después de emitirse, así que intercámbialo de inmediato.

app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;
  const { gh_state: savedState, gh_verifier: verifier } = req.cookies;

  res.clearCookie('gh_state', { path: '/auth/github' });
  res.clearCookie('gh_verifier', { path: '/auth/github' });

  if (typeof code !== 'string' || !verifier || !statesMatch(state, savedState)) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: GITHUB_REDIRECT_URI, // must match the authorize request
      code_verifier: verifier,
    }),
  });
  const token = await tokenRes.json();
  if (token.error || !token.access_token) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const profile = await getGitHubProfile(token.access_token);
  const user = await upsertUserByGitHubId(profile); // key on profile.id
  await saveGitHubTokens(user.id, token);           // server-side only
  await createSession(res, user.id);                // your own session cookie
  res.redirect('/');
});

Mantén este intercambio en el servidor, porque necesita el client secret y ese secreto nunca debe llegar al navegador. Cuando el intercambio se complete, emite tu propia cookie de sesión y conserva el token de GitHub en el servidor. Cookies vs localStorage for JWT auth analiza las ventajas e inconvenientes de cada opción de almacenamiento. El res.redirect('/') final elimina code y state de la barra de direcciones, de modo que pulsar Atrás no pueda reutilizar un código ya consumido.

Las grabaciones de sesión (session replays) de los inicios de sesión fallidos con GitHub muestran la secuencia completa: clic, GitHub, callback, página de error. Esa secuencia permite distinguir los errores de state no coincidente de los códigos caducados. Configura cualquier herramienta de session replay, analítica o logging para enmascarar los parámetros de consulta code y state.

Obtener el usuario y un email verificado

Llama a GET https://api.github.com/user con el token para identificar al usuario. El endpoint del usuario autenticado devuelve email: null cuando el usuario no ha configurado un email público. Además, los tokens OAuth necesitan read:user para obtener la respuesta privada del usuario.

const ghHeaders = (accessToken) => ({
  Authorization: `Bearer ${accessToken}`,
  Accept: 'application/vnd.github+json',
  'User-Agent': 'your-app-name',
});

async function getGitHubProfile(accessToken) {
  const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders(accessToken) });
  if (!userRes.ok) throw new Error(`GET /user failed: ${userRes.status}`);
  const user = await userRes.json();

  let email = user.email;
  if (!email) {
    const emailsRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders(accessToken) });
    if (emailsRes.ok) {
      const emails = await emailsRes.json();
      email = emails.find((e) => e.primary && e.verified)?.email ?? null;
    }
  }
  return { id: user.id, login: user.login, name: user.name, email };
}

Con el scope user:email, GET /user/emails devuelve todas las direcciones junto con los indicadores primary y verified. Elige la entrada en la que ambos sean verdaderos y nunca vincules cuentas a partir de una dirección no verificada. Identifica a los usuarios por el id numérico, porque los logins y los emails pueden cambiar. La API REST de GitHub rechaza cualquier petición que no incluya la cabecera User-Agent, así que defínela tú mismo. GitHub también recomienda comprobar quién es el usuario cada vez que obtienes un token nuevo, ya que el usuario puede haber cambiado de cuenta en la página de autorización.

Solicitar el mínimo scope necesario

El inicio de sesión con GitHub solo necesita dos scopes de OAuth: read:user y user:email. La referencia de scopes de GitHub define cada uno, y los usuarios ven todos los scopes que solicitas en la pantalla de consentimiento de GitHub.

NecesitasScope
Perfil: id, login, nombre, avatarread:user
Direcciones de emailuser:email
Contenido de los repositoriosrepo (acceso completo, incluidos los repos privados)

Solicitar repo para el inicio de sesión implica pedir a los usuarios acceso de lectura y escritura a su código privado, y muchos abandonarán en ese punto. Si más adelante necesitas acceso a repositorios para otra funcionalidad, lanza un flujo independiente en ese momento. GitHub permite como máximo diez tokens por cada combinación de usuario, app y scope, y no más de diez tokens nuevos por hora. Si un bucle de callbacks alcanza ese límite horario, GitHub se detiene y muestra al usuario una solicitud de reautorización.

Refrescar los tokens que caducan

Para refrescar un token de GitHub, envía grant_type=refresh_token mediante POST al endpoint de tokens. Al usar un refresh token, este queda invalidado junto con el token de acceso anterior, así que guarda el nuevo par antes de hacer otra llamada. Con los tokens con caducidad activados, los tokens de acceso caducan a las ocho horas y un refresh token sin usar caduca a los seis meses. Desde el cambio del 14 de agosto de 2026, las apps nuevas tienen este comportamiento por defecto. Las apps existentes pueden activarlo para un inicio de sesión concreto añadiendo el scope offline_access, que no añade ninguna solicitud de consentimiento, o habilitarlo en la configuración de la app.

async function refreshGitHubToken(refreshToken) {
  // No refresh token means a non-expiring token: nothing to refresh.
  if (!refreshToken) return null;

  const res = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });
  const data = await res.json();
  if (data.error === 'bad_refresh_token') return { reauth: true };
  if (data.error) throw new Error(data.error);

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
}

No puedes cambiar los scopes durante un refresco. bad_refresh_token significa que el usuario tiene que volver a iniciar sesión. No des por hecho que siempre se devolverá un refresh_token: algunas versiones de GitHub Enterprise Server ignoran offline_access y emiten un token sin caducidad.

¿OAuth app o GitHub App?

Para el inicio de sesión con GitHub por sí solo, basta con una OAuth app que solicite read:user user:email, aunque en general GitHub recomienda las GitHub Apps frente a las OAuth apps. La página comparativa de GitHub detalla todas las diferencias.

OAuth appGitHub App
Modelo de permisosScopes ampliosPermisos granulares
Selección de repositoriosTodos los repos que cubre el scopeEl usuario elige los repositorios
Duración del tokenCaducidad de 8 horas por defecto en las apps nuevas; de larga duración en las apps antiguas salvo que se activeCorta duración
Recomendación de GitHubCompatiblePreferida

Elige una GitHub App cuando tu producto actúe sobre repositorios. Para la identidad, el flujo de OAuth app descrito arriba es suficiente.

Checklist de seguridad

  • Envía state y PKCE S256 en cada petición y verifica ambos en el callback.
  • Envía el mismo redirect_uri en la petición de autorización y en la de token.
  • Desactiva el wildcard matching que no necesites.
  • Mantén el client secret y los tokens de GitHub en el servidor. Entrega al navegador únicamente tu propia cookie de sesión.
  • Redirige fuera del callback después del intercambio.
  • Gestiona las respuestas de token que no incluyan refresh_token.

El inicio de sesión con GitHub se reduce a tres peticiones: autorizar, intercambiar y obtener el usuario. La seguridad reside en los detalles que las rodean: un state custodiado por el servidor, un verifier de PKCE, un secreto que nunca sale de tu backend y un email cuya verificación has confirmado. Como siguiente paso, abre la configuración de tu OAuth app, añade tus URL de callback de preview y de 127.0.0.1, desactiva el wildcard matching y despliega las tres rutas anteriores.

Preguntas frecuentes

¿Por qué el endpoint de tokens de GitHub devuelve bad_verification_code?

GitHub devuelve bad_verification_code cuando el código que envías es incorrecto, ha caducado o no coincide con el emitido en la petición de autorización. Las causas habituales son un código con más de 10 minutos de antigüedad o un código que ya se intercambió, por ejemplo tras pulsar Atrás o tras una segunda petición al callback. Ese código no se puede recuperar. Reinicia el flujo de inicio de sesión para obtener uno nuevo.

¿Qué provoca redirect_uri_mismatch en el OAuth de GitHub?

GitHub devuelve redirect_uri_mismatch cuando el redirect_uri que envías no coincide con ninguna URL de callback registrada en la OAuth app. Envía exactamente una URL registrada, o bien omite el parámetro y GitHub usará la URL por defecto de la app. Envía el mismo valor en la petición de autorización y en la de token. GitHub trata 127.0.0.1 y localhost como hosts distintos, así que registra el que use tu servidor local.

¿Cerrar la sesión en mi app revoca el token de GitHub del usuario?

No. Terminar tu propia sesión deja válidos tanto el token de GitHub como la autorización. Para revocar un token concreto, llama a DELETE /applications/{client_id}/token. Para eliminar todos los tokens y la autorización de la app para ese usuario, llama a DELETE /applications/{client_id}/grant. Ambos endpoints usan autenticación Basic con el client_id y el client_secret, además del access_token en el cuerpo de la petición. Los usuarios también pueden revocar el acceso desde la configuración de su cuenta de GitHub.

¿Puede dejar de funcionar un token OAuth de GitHub sin caducidad?

Sí. GitHub revoca automáticamente cualquier token OAuth que no se use durante un año. Un token también deja de funcionar cuando el usuario revoca la autorización de tu app o cuando el token se publica en un repositorio público o en un gist. Si una misma combinación de usuario, app y scope acumula más de diez tokens, GitHub revoca uno de los más antiguos. Cuando un token almacenado deje de funcionar, haz que el usuario vuelva a pasar por el inicio de sesión.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.