Building a Minimal Single Sign-On (SSO) System from Scratch

Imagine you own a successful e-commerce website that sells audio equipment, serving millions of users. One day, your business analyst suggests that music streaming is predicted to be the next big thing, and you should invest in it immediately. Excited by the opportunity, you create a new website. However, there is one issue: users must create a new account even though they already have one with your company.

To enhance user experience and prevent duplicate accounts, we need a centralized system for identity management. This system would allow users to create a single account and use it to log in across multiple websites under your brand. Several technologies can enable this, but in this blog post, I’ll explore Single Sign-On (SSO) and build a minimal example from scratch.

Note: The code provided is intended for learning and exploration purposes only and should not be used in production as-is.

After doing some Google-fu on auth systems, I found this blog post: "How Hashnode implements SSO for blogs running on custom domains" which I really liked. It’s a minimal approach to enterprise and corporate solutions, which I prefer. So my SSO system is going to be inspired by that blog post.

We want to have one system where the user authenticates, and other related websites can use that authentication token to authenticate the user on their end. But there is a problem: browsers implement the Same-Origin Policy. It’s a security measure enforced by modern web browsers that prevents a website from accessing cookies or resources from a different origin (i.e., a different website).

This ensures that only the website that created the cookie can read or modify it, preventing malicious sites from stealing session data or performing unauthorized actions on behalf of users.

 

Here’s how we work around this limitation from the technical side:

  1. User visits our e-commerce website, let's say http://website-a.local.

  2. They click the Login button and get redirected to our Identity Provider website. We also attach a "redirect" URL parameter so that after authentication, the system knows where to return the user.

  3. When the user authenticates, we set an auth JWT-encoded cookie on the Identity Provider side to know that the user is authenticated. So when they get redirected to the Identity Provider again, they won’t need to re-enter their credentials, and the Identity Provider server will be able to redirect them back immediately.
    Then we create an auth JWT and some unique value that only the client will know. We save that UUID and JWT key-value pair to temporary storage on the server. Once that is done, we can send the user back where they came from, but this time we attach that unique value (UUID) to the URL parameter.

  4. Once the user is back on http://website-a.local, the website checks for the UUID URL parameter. If it finds it, it sends a request to the Identity Provider server with that UUID to retrieve the actual JWT.

  5. The Identity Provider returns a signed JWT as the request payload. The JWT is then stored as a cookie so http://website-a.local can now access it from cookie storage.

 

Here’s how it looks from the user's perspective:

  1. User visits our e-commerce website, let's say http://website-a.local.

  2. They click the Login button and get redirected to the login form on http://identity-provider.local.

  3. They enter their credentials, click Login, and get redirected to the dashboard on http://website-a.local.

  4. The user then goes to our other website at http://website-b.local.

  5. They click the Login button and are redirected to the dashboard without having to provide credentials, because they’ve already authenticated with the Identity Provider.

 

Let’s begin by implementing this from the e-commerce store website.

If we have "uuid" as a URL parameter, then use that value to exchange it for a JWT from the Identity Provider, and once the JWT is received, redirect to the user dashboard.

if (isset($_GET["uuid"])) {
	exchangeUUIDForJWT($_GET["uuid"]);
	header("Location: http://website-a.local:9001/dashboard");
}

The exchangeUUIDForJWT function does the work of sending the UUID to the Identity Provider, retrieving the JWT, and setting it as a cookie so the e-commerce website can access it.

function exchangeUUIDForJWT(string $uuid): void
{
	$url = "http://identity-provider.local:9000/jwt?uuid=" . $uuid;

	$ch = curl_init($url);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($ch, CURLOPT_HEADER, true);

	$response = curl_exec($ch);
	$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
	curl_close($ch);

	$jwt = substr($response, $headerSize);

	setcookie("jwt_auth", $jwt, time() + 3600, "/");
}

If the route is empty, then show the index page with links to login (which redirects to the Identity Provider website) and the dashboard:

} else if (empty($route)) {
	echo <<<HTML
	<h2 style="color: red">Website A</h2>
	<h3>Login to access your <a href="/dashboard">Dashboard</a></h3>
	<a href="http://identity-provider.local:9000/?redirect=$encodedUrl">Login</a>
	HTML;
}

If the route is "dashboard", then check if the user has a valid JWT cookie and allow access to the dashboard:

} else if ($route === "dashboard") {
	if (isset($_COOKIE["jwt_auth"]) && verifyJWT($_COOKIE["jwt_auth"], $publicKeyPath)) {
		$payload = getJWTPayload($_COOKIE["jwt_auth"]);

		echo "<h2 style='color: red'> Website A Dashboard </h2>";
		echo "<h3> Welcome " . $payload["name"] . " (" . $payload["email"] . ") </h3>";
	} else {
		echo "<p> You must be authenticated to access this page. </p>";
	}
}

Now let’s look into the Identity Provider code.

If the login form is submitted, check if the user is in the database and authenticate them if they exist.

if ($_SERVER["REQUEST_METHOD"] == "POST") {
	$email = $_POST["email"] ?? "";
	$password = $_POST["password"] ?? "";

	if ($email === $databaseUser["email"] && $password === $databaseUser["password"]) {
		authenticate($databaseUser, $keyValueStore, $privateKeyPath);
	} else {
		$error = "Invalid email or password.";
	}
} 

The authenticate() function creates a cookie on the Identity Provider to remember that the user is authenticated, so it doesn't ask for credentials again. It also creates a JWT and stores it temporarily so the client can claim it from the e-commerce website. After successful authentication, the user is redirected back with the UUID to claim the JWT later.

function authenticate(array $user, string $keyValueStore, string $privateKeyPath): void
{
	$uuid = uniqid();

	// Save JWT for external site to claim.
	$payload = [
		"id" => $user["id"],
		"name" => $user["name"],
		"email" => $user["email"],
		"iat" => time(),
		"exp" => time() + 3600 // Expires in 1 hour
	];

	$jwt = generateJWT($payload, $privateKeyPath);
	saveJWTTemporarily($jwt, $uuid, $keyValueStore);


	// Save separate cookie on Identity Provider to know that user is authenticated.
	$cookieData = [
		"id" => $user["id"],
		"exp" => time() + 3600, // Expires in 1 hour
	];

	$cookieJWT = generateJWT($cookieData, $privateKeyPath);
	setcookie("jwt_login", $cookieJWT, time() + 3600, "/");

	// Redirect back to external site with "uuid".
	if (isset($_GET["redirect"])) {
		header("Location: " . $_GET["redirect"] . "?" . http_build_query(["uuid" => $uuid]));
		exit;
	}
}

When the route is empty, show the login form. If the user is already authenticated, redirect them back without asking for credentials:

if (empty($route)) {
	if (isset($_COOKIE["jwt_login"]) && verifyJWT($_COOKIE["jwt_login"], $publicKeyPath)) {
		$data = getJWTPayload($_COOKIE["jwt_login"]);

		if ($data["id"] === $databaseUser["id"] && $data["exp"] > time()) {
			echo "You're Authenticated!";
			// If user is already authenticated with Identity Provider, then generate new JWT and send them back with "uuid".
			authenticate($databaseUser, $keyValueStore, $privateKeyPath);
		};
	}

	$email = $databaseUser["email"];
	$password = $databaseUser["password"];

	echo <<<HTML
	<h2>Identity Provider</h2>

	<form action="" method="POST">
		<input type="email" name="email" placeholder="Email" value="$email" required>
		<input type="password" name="password" placeholder="Password" value="$password" required>
		<button type="submit">Login</button>
	</form>
	HTML;

	if (!empty($error)) {
		echo "<p>$error</p>";
	}
}

When the client hits the "jwt" endpoint, it means they want to exchange the UUID for the JWT:

} else if ($route === "jwt") {
	$existingData = file_exists($keyValueStore) ? json_decode(file_get_contents($keyValueStore), true) : [];
	echo $existingData[$_GET["uuid"]];
	exit;
}

And that’s the basic idea. I really like how simple and minimal it turned out. The code provided isn't the cleanest, but its purpose is to stay simple and easy to understand. Other things to consider would be logout and refresh token flow. You can find the full code on my GitHub.

This article was updated on May 15, 2025