Every Social Login Provider Is Slightly Wrong (Here's How)
Google, Apple, GitHub, Microsoft, Facebook and LinkedIn all implement OAuth differently enough to break your assumptions. A field guide to the deviations that cost real debugging time, and the abstraction that survives them.
The pitch for social login is that OAuth is a standard, so you implement it once and add providers by configuration. The reality is that every major provider deviates from the specification, or from the implicit assumptions your code makes, in at least one way that will cost you a day.
None of these deviations are secrets. They are all documented somewhere. The problem is that you discover them one at a time, in production, usually from a user reporting that sign-in is broken in a way you cannot reproduce. This article collects the ones that actually cost time, and the abstraction shape that absorbs them.
The assumptions that break
Before the provider-by-provider list, here are the four assumptions almost every first implementation makes. Each is false for at least one major provider.
- The provider returns an email address. Not always. Sometimes never, sometimes only if the user has one set to public, sometimes only on the first authorization ever.
- The email is stable. For at least one major provider, the address you receive can change identity semantics between logins.
- The user identifier is a string you can store. Mostly true, but the stability guarantees differ, and one provider's identifier is not global.
- You can call the userinfo endpoint whenever you need profile data. Some providers rate-limit it aggressively, some do not have one, and some return different fields there than in the ID token.
Everything below is a specific instance of one of these breaking.
The most standards-compliant of the group, and still not free of surprises.
email_verified is genuine — for Gmail accounts. For Google Workspace accounts on custom domains, the claim reflects the domain administrator's configuration, which is usually fine but is a different trust story. Consider whether the account linking rules you apply should distinguish them.
Refresh tokens arrive once. Google only returns a refresh_token on the first authorization, unless you explicitly send access_type=offline&prompt=consent. The classic failure: it works in development (first ever consent), then in production every returning user gets no refresh token and your background sync silently stops. If you need offline access, request it explicitly and store the refresh token permanently the one time you receive it.
hd is the enterprise hook. The hosted-domain claim tells you which Workspace domain the account belongs to, which is how you map a Google login to a tenant. It is absent for personal accounts. Do not use the email domain as a substitute — @example.com in the email does not prove the account is governed by that Workspace.
Unverified refresh tokens expire. Refresh tokens for apps still in "testing" publishing status expire in seven days. This produces a spectacular launch-week bug where everything worked for a week and then stopped.
Apple
The most different, by a wide margin. Budget separate time for it.
The user's name is returned exactly once, and not in the token. On the very first authorization, Apple POSTs a user field in the form body containing the name. On every subsequent authorization, it is gone forever, and it is not in the ID token or any userinfo endpoint. If you did not persist it on that first callback, you cannot get it. There is no recovery, no re-request, nothing. Handle this before launch or you will have a user table full of nulls.
Private Relay addresses. Users can choose to hide their real email, and you receive something like [email protected]. This is a real, deliverable address that forwards — but only if you have registered and verified your sending domain with Apple. If you have not, every email you send to those users bounces silently. It also means the address tells you nothing about the person, and matching it against an existing account is meaningless.
The response comes back as a POST. Apple uses response_mode=form_post, so your callback route must accept POST, not GET. If your framework's CSRF middleware guards all POSTs, it will reject Apple's callback — a classic first-integration failure.
The client secret is a JWT you sign, and it expires. There is no static client secret. You generate an ES256-signed JWT from a private key downloaded from the developer portal, valid for at most six months. You must build key management and rotation for this, and a monitor for the expiry, or your Apple sign-in stops working on a date you will have forgotten.
The sub is per-developer-team. The identifier is stable for your team but is not a global Apple user ID, so you cannot correlate it with anything else.
GitHub
Not an OIDC provider at all — plain OAuth 2.0, no ID token, no discovery document, no nonce. You call the API to learn who the user is.
The primary email is a separate API call. The /user endpoint returns the publicly visible email, which is null for the many developers who keep it private. To get the real one you call /user/emails with the user:email scope and select the entry that is both primary and verified. Skipping this yields a large population of accounts with no email address.
Usernames are mutable and reusable. A user can rename themselves, and the old name becomes available for someone else to claim. Key on the numeric id, never the login. Storing the username as an identifier is a slow-motion account takeover.
Access tokens historically did not expire. Depending on the app type and settings, a GitHub token may be long-lived or effectively permanent. Treat it with the care that implies, and prefer the newer expiring-token configuration.
Microsoft (Entra ID)
Standards-compliant and structurally more complicated than the others, because it serves both personal and organizational accounts through the same endpoints.
Choose your tenant endpoint deliberately. /common accepts both personal Microsoft accounts and any organization's accounts. /organizations accepts work accounts only. /consumers accepts personal only. A specific tenant ID restricts to one organization. Using /common when you meant "our enterprise customers" means anyone with an Outlook.com address can sign in.
Do not authorize on the email claim. For multi-tenant apps, the guidance is explicit: the pair of tid (tenant) and oid (object ID) is the immutable identifier. The email and preferred_username claims are mutable and, critically, not guaranteed unique across tenants. An application that treats email as the key can be tricked by a user in a different tenant who sets a matching value. This is the highest-severity item in this article.
email is often simply absent. Many organizational accounts do not have it populated in the token. preferred_username usually has something; it may not be a working mailbox.
Email requires a scope, and can still be missing. Users can register with a phone number and have no email at all, or can decline the email permission on the consent screen. Your flow must handle a successful authentication that yields no address — which means either a prompt to supply one or an account model that tolerates its absence.
Permissions are revocable individually and asynchronously. A user can go into Facebook's settings and remove the email permission from your app after the fact. Your next API call gets less than it did last time, without any error at authorization time.
Token lifetimes come in tiers. Short-lived tokens must be exchanged for long-lived ones through a separate call. This is not a refresh token flow; it is its own mechanism.
Historically a partial OAuth implementation. LinkedIn has moved toward OIDC compliance, but older integrations and older API versions behave differently enough that a copy-pasted example from a blog post is likely to be wrong for the version you are on. Check the current API version rather than trusting any example, including this one.
Aggressive rate limits on profile endpoints, applied per-app rather than per-user. A burst of signups can exhaust the quota and turn into intermittent login failures that look like a bug in your code.
The abstraction that survives all of this
Given the above, the natural instinct is a normalization layer. The mistake is normalizing too hard — flattening every provider into a single shape and discarding what made them different, which is exactly the information you need when something goes wrong.
The shape that holds up keeps three things separate:
A canonical profile, for your application to consume:
interface NormalizedIdentity {
provider: string;
providerAccountId: string; // ALWAYS the immutable ID, never a username or email
email: string | null; // null is a real, common outcome
emailVerifiedByProvider: boolean; // per-provider trust decision, not a blind claim copy
displayName: string | null;
avatarUrl: string | null;
raw: unknown; // the untouched provider response
}
The raw response, stored. When a user reports something strange six months from now, the raw payload is the only thing that can tell you what the provider actually sent. It costs a JSONB column.
Per-provider capability flags in configuration, not in code branches scattered through the flow: does this provider verify emails, can it omit the email entirely, is the display name available only once, does it require form_post, is the identifier globally unique. Your linking logic then reads a capability rather than special-casing a provider name in six different files.
The integration checklist
For every provider you add, answer these before you ship:
- What is the immutable identifier, and is it globally unique or scoped to your app?
- Can the flow succeed with no email? What does your UI do then?
- Is the email verified, and do you trust this provider's claim about that?
- What data is available only once, and are you persisting it on first callback?
- Does the callback arrive as
GETorPOST, and does your CSRF middleware allow it? - Does the client secret expire, and what monitors it?
- What are the token lifetimes, and do you need a refresh token — and if so, what must you send to receive one?
- What happens when the user revokes permission from the provider's side?
Write the answers down next to the provider config. Every one of these has been a production incident for somebody.
Key takeaways
- Key on the immutable provider ID. Never a username, never an email. GitHub usernames are reusable; Microsoft emails are not unique across tenants.
- An email address is optional. Design the flow for its absence rather than treating it as an error.
- Apple is a separate project, not a config entry: one-time name, private relay,
form_post, and a signed, expiring client secret. - Google's refresh token arrives once, and only if you ask correctly.
- Trust
email_verifiedper provider, as an explicit configuration decision. - Normalize to a canonical profile, keep the raw payload, and put provider differences in capability flags rather than scattered branches.
EmbedAuth handles this normalization layer for you — one identity shape, per-provider verification semantics, and the callback plumbing each provider insists on — so adding a provider is configuration rather than a fresh set of quirks to discover.
Written by
Emilian GheoneaSenior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.
Related articles
OAuth & OIDC
SAML vs OIDC: Which SSO Standard Should You Support?
A clear comparison of SAML and OpenID Connect for single sign-on — how each works, XML vs JSON, where they fit, and what B2B SaaS teams should support for enterprise customers.
Jul 3, 2026 6 minReadOAuth & OIDC
OAuth Token Exchange: Delegation, Impersonation, and Service-to-Service Auth
RFC 8693 explained — how token exchange solves the confused deputy problem in microservices, the difference between delegation and impersonation, act and may_act claims, and when a simpler answer is better.
Jul 23, 2026 7 minReadOAuth & OIDC
Designing OAuth Scopes and Consent Screens People Actually Read
How to design a scope taxonomy that survives five years of product growth — granularity, naming, incremental authorization, consent screen copy, and the downgrade and revocation paths most APIs never build.
Jul 18, 2026 8 minReadOAuth & OIDC
OAuth Popup vs Redirect: Which Flow Should You Use?
A practical comparison of popup-based and full-redirect OAuth flows — UX, state preservation, mobile and browser quirks, security trade-offs, and how each behaves inside an embedded iframe.
Jun 27, 2026 6 minRead