Previous Article
PHP Cookies: setcookie(), SameSite, and What Changed in 2026
16 July 2018
"Log in with Facebook" is a useful signup shortcut, and the code that circulates for it — including the earlier version of this article — is a catalogue of things that no longer work and things that were never safe. It uses a deleted SDK, a removed permission, and interpolates a value straight into SQL.
This guide covers Facebook Login as it works in 2026: the server-side Authorization Code flow, done securely.
The widely copied version has, in one file, all of these:
"SELECT ... WHERE fbid=$uid" and "INSERT ... VALUES ($uid, '$fullname', '$email')" drop user-controlled values straight into queries.publish_actions was deleted in 2018. Requesting it now fails app review outright.all.js and the old new Facebook([...]) PHP SDK no longer exist. SDK v2.2 expired years ago.state parameter, so the login callback can be forged.latin1 tables that cannot store many real names.The fix is not to patch these one by one. The whole flow moved server-side, for good reasons.
Modern Facebook Login is an OAuth 2.0 / OpenID Connect flow. The pattern is identical to "Sign in with Google" — so if you have implemented one, the other is the same shape with different endpoints. The social login guide covers the full flow with PKCE; this focuses on the Facebook specifics.
FB_APP_ID=your_app_id FB_APP_SECRET=your_app_secret # never in client code, never committed FB_REDIRECT_URI=https://example.com/auth/facebook/callback
<?php
declare(strict_types=1);
session_start();
// Unguessable, single-use, stored in the session — this is the CSRF defence.
$_SESSION['fb_state'] = bin2hex(random_bytes(32));
$params = http_build_query([
'client_id' => $_ENV['FB_APP_ID'],
'redirect_uri' => $_ENV['FB_REDIRECT_URI'],
'state' => $_SESSION['fb_state'],
// Request only what you need. email + public_profile need no app review.
'scope' => 'email,public_profile',
'response_type' => 'code',
]);
header('Location: https://www.facebook.com/v21.0/dialog/oauth?' . $params);
exit;
Your login button is now a plain link to this script — no SDK, no JavaScript required:
<a class="btn-facebook" href="/auth/facebook/start">Log in with Facebook</a>
Request the minimum scope. Anything beyond email and public_profile triggers Facebook's App Review, which is a real process with real delays.
<?php
declare(strict_types=1);
session_start();
// Reject anything whose state does not match — blocks CSRF and forged callbacks.
if (empty($_GET['state']) || !hash_equals($_SESSION['fb_state'] ?? '', $_GET['state'])) {
http_response_code(400);
exit('Invalid state.');
}
unset($_SESSION['fb_state']); // single use
if (isset($_GET['error'])) {
// User clicked "Cancel" — not an error, just send them back.
header('Location: /login');
exit;
}
$code = $_GET['code'] ?? '';
if ($code === '') {
http_response_code(400);
exit('Missing authorization code.');
}
// Exchange the code for an access token — server-side, with the app secret.
$tokenResponse = httpGetJson('https://graph.facebook.com/v21.0/oauth/access_token?' . http_build_query([
'client_id' => $_ENV['FB_APP_ID'],
'client_secret' => $_ENV['FB_APP_SECRET'],
'redirect_uri' => $_ENV['FB_REDIRECT_URI'],
'code' => $code,
]));
$accessToken = $tokenResponse['access_token'] ?? null;
if ($accessToken === null) {
http_response_code(502);
exit('Could not obtain an access token.');
}
An access token obtained legitimately for a different app must not be accepted by yours. Facebook's debug endpoint confirms which app a token belongs to:
$appToken = $_ENV['FB_APP_ID'] . '|' . $_ENV['FB_APP_SECRET'];
$debug = httpGetJson('https://graph.facebook.com/debug_token?' . http_build_query([
'input_token' => $accessToken,
'access_token' => $appToken,
]));
if (($debug['data']['app_id'] ?? '') !== $_ENV['FB_APP_ID']
|| ($debug['data']['is_valid'] ?? false) !== true) {
http_response_code(401);
exit('Token failed validation.');
}
Skipping this check is the "confused deputy" vulnerability — your app accepting a token minted for someone else's.
$me = httpGetJson('https://graph.facebook.com/v21.0/me?' . http_build_query([
'fields' => 'id,name,email',
'access_token' => $accessToken,
]));
$facebookId = $me['id'] ?? null; // stable, app-scoped
$name = $me['name'] ?? '';
$email = $me['email'] ?? null; // may be absent — see below
if ($facebookId === null) {
http_response_code(502);
exit('Could not read profile.');
}
Facebook may not return an email. Users who registered with a phone number, or who declined the email permission, have none. Your code must handle a null email rather than assuming one — the original crashed on exactly this.
CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, facebook_id VARCHAR(64) NULL, name VARCHAR(150) NOT NULL, email VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uniq_facebook_id (facebook_id), UNIQUE KEY uniq_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// Look up by the Facebook ID — prepared statement, no interpolation.
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE facebook_id = ?');
$stmt->execute([$facebookId]);
$user = $stmt->fetch();
if ($user === false) {
$stmt = $pdo->prepare(
'INSERT INTO users (facebook_id, name, email, created_at) VALUES (?, ?, ?, NOW())'
);
$stmt->execute([$facebookId, $name, $email]);
$userId = (int) $pdo->lastInsertId();
} else {
$userId = (int) $user['id'];
}
// New session ID on login — prevents session fixation.
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
header('Location: /dashboard');
exit;
Two things the original got dangerously wrong here. Every value goes through a prepared statement — fbid=$uid was a textbook injection. And the lookup key is the Facebook ID, not the email.
This is the one that turns social login into account takeover, and it is worth stating plainly.
The tempting shortcut is: match the returned email to an existing account and log that user in. But if an attacker can create a Facebook account using victim@example.com without proving they own it, they walk straight into the victim's account on your site.
The rules:
(provider, subject) pair), never the email alone.function httpGetJson(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $status >= 400) {
return [];
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR) ?? [];
}
Standard session teardown logs the user out of your site. Note that Facebook also requires a Data Deletion Callback — an endpoint users can trigger from their Facebook settings to have you erase their data. It is a condition of using Facebook Login, and apps get disabled for not providing one.
state parameter.debug_token.publish_actions is gone.utf8mb4, and provide a data deletion callback.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.