JavaScript kod na tamnom ekranu

Kako implementirati social login u Node.js-u uz Google

13/11/2026

Kako implementirati social login u Node.js-u? Aplikacija pošalje korisnika na Google, Google ga posle prijave vrati na vašu callback adresu sa jednokratnim kodom, a server taj kod direktnim zahtevom Google-u zameni za ID token. Iz tokena pročita ko je korisnik i otvori mu sopstvenu sesiju. U Express-u su za to dovoljna tri paketa (express, express-session i openid-client), pet ruta i jedan fajl od 133 linije, koji je ceo dat u nastavku.

Google u celoj priči odgovara na jedno pitanje: ko je ovaj korisnik. Sve posle toga pripada vašoj aplikaciji, od kolačića sesije do identifikatora pod kojim korisnika čuvate u bazi. Zato greške u social loginu retko nastaju na Google-ovoj strani. Tipični kvarovi su kolačić koji ne stigne nazad na callback, korisnik vezan za e-mail umesto za stalni ID i sesija koja posle prijave zadrži stari identifikator. Primer u nastavku rešava sva tri.

Kako teče prijava: OAuth 2.0 i OpenID Connect

OAuth 2.0 je napravljen za ovlašćenja. Korisnik njime dozvoljava aplikaciji da u njegovo ime koristi neki API, recimo da čita njegov Google kalendar, ali sam OAuth aplikaciji ne kaže ko je korisnik. To dodaje OpenID Connect (OIDC), sloj iznad OAuth-a: server za autorizaciju uz access token izdaje i ID token, potpisan JWT sa podacima o korisniku. Za prijavu preko Google-a traži se scope openid email profile i čita ID token.

Ceo tok ima pet koraka:

  1. Korisnik klikne „Prijavite se preko Google naloga”. Server napravi tri nasumične vrednosti (state, nonce i PKCE code_verifier), sačuva ih u sesiji i preusmeri pregledač na Google-ov authorization endpoint.
  2. Korisnik se prijavljuje na Google i odobrava deljenje imena i e-mail adrese. Lozinku vaša aplikacija nikad ne vidi.
  3. Google vraća pregledač na redirect URI, u primeru /auth/google/callback?code=...&state=....
  4. Server proveri state, pa kod, client secret i code_verifier pošalje na Google-ov token endpoint. Taj zahtev ide sa servera, mimo pregledača.
  5. U odgovoru stiže ID token. Biblioteka proveri izdavaoca, kome je token namenjen (aud), rok važenja i nonce, a server iz podataka u tokenu napravi svoju sesiju.
Dijagram toka prijave preko Google-a između pregledača, Express servera i Google-a
Pet koraka prijave preko Google-a.

Svaka od te tri vrednosti ima svoju ulogu. state vezuje odgovor za zahtev koji je pokrenuo baš ovaj pregledač, pa napadač ne može da vam podmetne kod iz svoje prijave (CSRF). nonce završi u ID tokenu i štiti od ponovnog korišćenja starog tokena. PKCE obezbeđuje da kod može da zameni samo onaj ko je započeo prijavu, čak i ako kod negde procuri. RFC 9700, IETF-ov dokument o bezbednosti OAuth-a iz januara 2025., preporučuje PKCE i serverskim aplikacijama koje imaju client secret, a implicit flow, u kome token stiže direktno u URL-u, ne preporučuje. Google podržava PKCE sa metodom S256, pa primer koristi sve tri zaštite.

Šta vam treba pre početka

Dovoljna je bilo koja LTS linija Node.js-a, a u novembru 2026. to su 22, 24 i 26. openid-client 6 traži najmanje Node.js 20, a ta linija je van podrške od 30. aprila 2026. Verziju pokazuje node -v. Ako komanda ne radi ili niste sigurni koja je verzija aktivna, pogledajte kako da proverite da li je Node.js instaliran, a za prelazak na noviju verziju kako da ažurirate Node.js.

Primer učitava tajne Node-ovim flagom --env-file, pa vam paket dotenv ne treba. Flag postoji od verzije 20.6, a od 24.10.0 više nije eksperimentalan. Treba vam još Google nalog sa pristupom Google Cloud konzoli. Verzije paketa u trenutku pisanja (novembar 2026.) su express 5.3.0, express-session 1.19.0 i openid-client 6.8.9.

OAuth klijent u Google Cloud konzoli

Aplikaciju i adresu na koju Google sme da vrati korisnika registrujete u delu konzole koji se zove Google Auth Platform.

  1. Na console.cloud.google.com napravite projekat ili izaberite postojeći.
  2. Otvorite Google Auth Platform → Branding. Ako konzola javi da platforma još nije podešena, kliknite Get Started, unesite naziv aplikacije i e-mail za podršku, za Audience izaberite External, dodajte kontakt e-mail, prihvatite uslove i kliknite Create.
  3. U delu Clients kliknite Create Client i za Application type izaberite Web application.
  4. Pod Authorized redirect URIs dodajte http://localhost:3000/auth/google/callback. Polje Authorized JavaScript origins ostavite prazno, jer prijava ide preko servera.
  5. Kliknite Create i odmah sačuvajte Client ID i Client secret.

Peti korak shvatite bukvalno. Google ceo client secret prikazuje samo jednom, pri kreiranju, a posle toga konzola pokazuje samo poslednja četiri znaka. Ako ga izgubite, preko rotacije dodajete novi (klijent može da ima najviše dva), prebacite aplikaciju na njega i ugasite stari. Google takođe automatski briše OAuth klijente koji šest meseci nisu korišćeni.

Redirect URI mora tačno da se poklopi sa onim što aplikacija šalje, uključujući http ili https, port i završnu kosu crtu. Google za sve osim localhost-a traži HTTPS i ne prihvata džoker znakove ni golu IP adresu.

Novi projekat je u statusu Testing, u kome se inače mogu prijaviti samo navedeni test korisnici, najviše njih 100. Za aplikaciju koja traži samo openid, email i profile to ograničenje ne važi: prema Google-ovoj dokumentaciji može da se prijavi bilo koji Google nalog, a odobrenja ne ističu posle sedam dana. Naziv i logo aplikacije ekran za saglasnost ipak neće prikazati dok Google ne verifikuje brend.

Projekat i tajne u .env fajlu

Komande rade isto u PowerShell-u, na macOS-u i na Linuxu:

mkdir google-login
cd google-login
npm init -y
npm pkg set type=module
npm install express express-session openid-client

npm pkg set type=module upisuje "type": "module" u package.json. Zato Node.js učitava server.js kao ES modul, sa import naredbama i top-level await-om. npm na kraju ispisuje broj dodatih paketa (added ...) i rezultat provere ranjivosti.

Za tajnu kojom express-session potpisuje kolačić napravite 32 nasumična bajta:

node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Komanda ispisuje 64 heksadecimalna znaka. Njih, zajedno sa podacima iz Google konzole, upišite u fajl .env u direktorijumu projekta:

GOOGLE_CLIENT_ID=vaš-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=vaš-client-secret
SESSION_SECRET=64-znaka-iz-prethodne-komande
BASE_URL=http://localhost:3000

Napravite i .gitignore sa ova dva reda, da .env nikad ne završi u repozitorijumu:

.env
node_modules

Ceo server.js

Fajl server.js ide u isti direktorijum:

import express from 'express';
import session from 'express-session';
import * as client from 'openid-client';

const {
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  SESSION_SECRET,
  BASE_URL = 'http://localhost:3000',
  PORT = 3000,
  NODE_ENV,
} = process.env;

if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET || !SESSION_SECRET) {
  throw new Error('Nedostaju GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET ili SESSION_SECRET.');
}

const isProduction = NODE_ENV === 'production';
const redirectUri = new URL('/auth/google/callback', BASE_URL).href;

// Discovery: biblioteka čita https://accounts.google.com/.well-known/openid-configuration
const config = await client.discovery(
  new URL('https://accounts.google.com'),
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
);

const app = express();

if (isProduction) {
  app.set('trust proxy', 1); // HTTPS se završava na proxy-ju ispred aplikacije
}

app.use(session({
  name: 'sid',
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: isProduction,
    maxAge: 8 * 60 * 60 * 1000, // 8 sati
  },
}));

// express-session radi sa callback funkcijama, pa ih pretvaramo u Promise
const saveSession = (req) =>
  new Promise((resolve, reject) => req.session.save((err) => (err ? reject(err) : resolve())));
const regenerateSession = (req) =>
  new Promise((resolve, reject) => req.session.regenerate((err) => (err ? reject(err) : resolve())));

app.get('/', (req, res) => {
  if (!req.session.user) {
    return res.send('<a href="/auth/google">Prijavite se preko Google naloga</a>');
  }
  res.send(
    '<p>Prijavljeni ste. <a href="/me">Podaci o nalogu</a></p>' +
    '<form method="post" action="/logout"><button>Odjavite se</button></form>',
  );
});

app.get('/auth/google', async (req, res) => {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();

  const authUrl = client.buildAuthorizationUrl(config, {
    redirect_uri: redirectUri,
    scope: 'openid email profile',
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: 'S256',
    state,
    nonce,
  });

  req.session.oidc = { codeVerifier, state, nonce };
  await saveSession(req);
  res.redirect(authUrl.href);
});

app.get('/auth/google/callback', async (req, res) => {
  const pending = req.session.oidc;
  if (!pending) {
    return res.status(400).send('Prijava nije započeta ili je sesija istekla. <a href="/">Pokušajte ponovo</a>.');
  }
  delete req.session.oidc;

  const tokens = await client.authorizationCodeGrant(
    config,
    new URL(req.originalUrl, BASE_URL),
    {
      pkceCodeVerifier: pending.codeVerifier,
      expectedState: pending.state,
      expectedNonce: pending.nonce,
    },
  );
  const claims = tokens.claims();

  await regenerateSession(req);
  req.session.user = {
    googleId: claims.sub,
    email: claims.email,
    emailVerified: claims.email_verified,
    name: claims.name,
  };
  await saveSession(req);
  res.redirect('/');
});

app.get('/me', (req, res) => {
  if (!req.session.user) {
    return res.status(401).json({ error: 'Niste prijavljeni.' });
  }
  res.json(req.session.user);
});

app.post('/logout', (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);
    res.clearCookie('sid');
    res.redirect('/');
  });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).send('Prijava nije uspela. <a href="/">Pokušajte ponovo</a>.');
});

app.listen(PORT, () => {
  console.log(`Server radi na ${BASE_URL}`);
});

Discovery i podešavanje sesije

client.discovery() pri pokretanju preuzme Google-ov dokument sa https://accounts.google.com/.well-known/openid-configuration i iz njega pročita adrese za autorizaciju, tokene i ključeve. Google-ova OIDC dokumentacija preporučuje baš to: u kodu držite adresu discovery dokumenta, a ne pojedinačne endpoint-e. Ako Google u tom trenutku nije dostupan, server se neće ni pokrenuti.

Sesija je podešena sa saveUninitialized: false, pa posetilac dobija kolačić tek kad započne prijavu. Vrednost sameSite: 'lax' ovde je obavezna. Povratak sa Google-a je navigacija sa drugog sajta, a kolačić sa SameSite=Strict pregledač uz nju ne bi poslao. Callback bi tada video praznu sesiju i odbio prijavu.

Početak prijave

Google-u ide samo izvedeni code_challenge, a code_verifier ostaje u sesiji uz state i nonce. Poziv saveSession() pre preusmerenja nije višak. express-session inače upisuje sesiju na kraju odgovora, a zaglavlje sa preusmerenjem tada ode ka pregledaču pre nego što je upis završen. Kad je korisnik već prijavljen na Google, povratak na callback može da bude brži od upisa u Redis ili bazu.

Callback: provera i nova sesija

Callback prvo traži podatke iz sesije. Ako ih nema, prijava nije počela u ovom pregledaču ili se kolačić izgubio, pa korisnik dobija poruku umesto greške servera. Podaci se odmah brišu, da isti state ne bi mogao da se iskoristi dvaput.

Adresa koja ide u authorizationCodeGrant() pravi se iz BASE_URL, a ne iz zaglavlja zahteva. openid-client iz nje, bez query dela, izvodi redirect_uri za token endpoint, a Google ga upoređuje sa registrovanim znak po znak. Iza proxy-ja zahtev do aplikacije često stiže preko običnog HTTP-a ili sa internim imenom hosta, pa se URI izveden iz zaglavlja ne bi poklopio.

Svako odstupanje u state, razmeni koda ili ID tokenu baca grešku, a Express 5 greške iz async handlera sam prosleđuje error middleware-u, pa try/catch nije potreban. Potpis ID tokena biblioteka u ovom toku ne proverava i to je ispravno: token stiže direktnim HTTPS zahtevom od Google-a, što i Google i OpenID Connect specifikacija priznaju kao dovoljnu potvrdu porekla. Ako ID token prosledite drugom servisu, taj servis mora sam da ga verifikuje.

Posle uspešne provere req.session.regenerate() daje korisniku novu sesiju sa novim ID-jem. Time se sprečava session fixation: ako je napadač unapred podmetnuo ID sesije, posle prijave ostaje sa praznom sesijom. U sesiju ulaze samo podaci koji su aplikaciji potrebni, a korisnik se identifikuje preko tvrdnje (claim) sub, sačuvane kao googleId.

/me i odjava

/me vraća JSON, pa frontend preko te rute saznaje ko je prijavljen. Početna strana namerno ne ubacuje ime iz Google naloga u HTML, jer bi ga tada morala zaštititi od XSS-a. Odjava ide preko POST forme, pa tuđi sajt ne može da odjavi vašeg korisnika: kolačić sa SameSite=Lax ne stiže uz POST sa drugog sajta.

Odjava briše samo sesiju u vašoj aplikaciji. Korisnik ostaje prijavljen na Google i sledeća prijava obično prolazi bez lozinke. Na deljenim računarima dodajte prompt: 'select_account' u parametre za buildAuthorizationUrl() i Google će svaki put ponuditi izbor naloga.

Pokretanje i provera

node --env-file=.env server.js

U terminalu treba da se pojavi Server radi na http://localhost:3000. Otvorite baš tu adresu, sa localhost, i kliknite link za prijavu. Posle Google-ovog ekrana za izbor naloga i saglasnost vraćate se na početnu stranu, a http://localhost:3000/me pokazuje nešto ovako:

{"googleId":"1234567890","email":"ime@gmail.com","emailVerified":true,"name":"Ime Prezime"}

Ako nešto ne prođe, uzrok se obično vidi iz poruke:

Šta viditeUzrokRešenje
.env: not found pri pokretanjuKomanda je pokrenuta iz drugog direktorijumaPokrenite je iz direktorijuma projekta
Error: Nedostaju GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET ili SESSION_SECRET.U .env fali promenljiva ili je ime pogrešno napisanoUporedite imena sa primerom iznad
Google prikazuje redirect_uri_mismatchURI u konzoli se razlikuje od BASE_URL sa dodatkom /auth/google/callbackIzjednačite šemu, port i završnu kosu crtu
„Prijava nije započeta ili je sesija istekla.”Kolačić nije stigao na callback: strana je otvorena preko 127.0.0.1 umesto localhost, server je restartovan usred prijave ili je sameSite promenjen u strictOtvarajte aplikaciju na adresi iz BASE_URL i ne restartujte server dok traje prijava
„Prijava nije uspela.”, a u terminalu AuthorizationResponseErrorKorisnik je na Google-u otkazao prijavuNije greška u kodu; korisnik može da pokuša ponovo. U produkciji taj slučaj obradite posebno i vratite 400 umesto 500.
„Prijava nije uspela.”, a u terminalu ClientError: invalid response encountered i niže, pod [cause], unexpected "state" response parameter valuePrijava je pokrenuta u dve kartice pregledača, pa je druga zamenila state prvePokrenite prijavu ponovo u jednoj kartici

Pre produkcije

Za pravi sajt menjate tri stvari: BASE_URL postaje https:// adresa sajta, postavljate NODE_ENV=production, a produkcijski callback dodajete kao drugi URI u isti Google klijent.

  • Korisnika vežite za sub, ne za e-mail. Google navodi da je sub jedinstven za nalog i ostaje isti kad korisnik promeni e-mail adresu, dok e-mail može da se promeni i ne mora da bude jedinstven. Ako postojeće naloge sa lozinkom povezujete sa Google nalozima preko e-maila, radite to samo kad je email_verified tačno.
  • Ograničenje na domen firme proverava se u tokenu. Parametar hd u zahtevu samo prilagođava Google-ov izbor naloga, a Google upozorava da se na njega ne oslanjate kad kontrolišete pristup. Pristup ograničavate proverom tvrdnje hd u ID tokenu.
  • trust proxy ostaje uključen samo iza proxy-ja. Na većini PaaS platformi i na VPS-u sa Nginx-om ispred aplikacije HTTPS se završava na proxy-ju, pa Express vidi običan HTTP. Zato primer u produkciji poziva app.set('trust proxy', 1): bez toga express-session ne šalje kolačić sa oznakom secure i callback javlja da prijava nije započeta. Ako ispred aplikacije nema proxy-ja, taj red obrišite, jer inače svaki klijent može sam da pošalje zaglavlje X-Forwarded-Proto. Kako se Node.js aplikacija postavlja na takve platforme pokazuje pregled deployment servisa za Node.js.
  • Sesije čuvajte van memorije. Podrazumevani MemoryStore autori express-session-a izričito ne preporučuju za produkciju: ne radi sa više procesa, a svaki restart odjavljuje sve korisnike. Na istoj strani je lista kompatibilnih store-ova, sa paketima za Redis, PostgreSQL i druge baze.
  • Tajne ne idu u kod. Na serveru .env zamenite promenljivama okruženja koje nudi platforma ili Node.js hosting. Opcija secret u express-session-u prima i niz, pa tajnu možete da zamenite bez odjavljivanja korisnika: nova ide na prvo mesto i njome se potpisuju novi kolačići, a stara ostaje iza nje dok postojeće sesije ne isteknu.
  • Tokene ne šaljite u pregledač. Od svega što Google vrati, primer zadržava samo četiri podatka iz ID tokena. Ako kasnije budete pozivali Google API-je, access token i refresh token držite na serveru, uz sesiju ili u bazi.

Kada je bolji Passport ili gotov auth framework

openid-client je ovde izabran jer pokazuje svaki korak toka i sertifikovan je prema OpenID Connect profilima za relying party (Basic, FAPI 1.0 i FAPI 2.0). Druge opcije se razlikuju po tome koliko posla ostaje u vašem kodu.

PristupUzmite ga kadImajte u vidu
openid-client i express-session (ovaj primer)Imate jednog ili dva OIDC provajdera i želite da kontrolišete svaki korakTabelu korisnika, povezivanje naloga i store za sesije pravite sami
Passport i passport-google-oauth20Projekat već koristi Passport za druge strategijePoslednje izdanje ove strategije je 2.0.0 iz marta 2019. openid-client ima i sopstvenu Passport strategiju, openid-client/passport
Better AuthTreba vam više provajdera, prijava lozinkom i upravljanje nalozima na jednom mestuKorisnike čuva u vašoj bazi (SQLite, PostgreSQL, MySQL ili preko ORM adaptera); verzija u novembru 2026. je 1.7.21

GitHub je dobar primer zašto broj provajdera menja računicu. GitHub prijava nije OpenID Connect: token endpoint vraća access token bez ID tokena, a ko je korisnik saznajete pozivom GET https://api.github.com/user sa tim tokenom. Za prijavu preko Google-a, uz bazu korisnika koju već imate, kod iz ovog teksta je dovoljan. Kad provajdera bude tri ili više, ili kad uz njih zatreba i prijava lozinkom, Better Auth preuzima razlike poput ove sa GitHub-om, koje biste inače pisali sami.