Social identity
This guide covers the withSocialIdentity mixin from @adonisplus/persona/social. You will learn how to:
- Apply the mixin and understand the identity linking model
- Implement social signup and login flows
- Handle email collisions when a provider email is already in use
- Let users connect, re-sync, and disconnect providers from their account
Overview
Social authentication lets users sign in using accounts they already have with providers like Google or GitHub. Instead of creating a new username and password, the provider confirms who they are, and your application receives a profile with their name, email, and a unique user ID.
The important part is what your app stores after that handshake. The provider's unique user ID is the stable, durable identifier. It does not change when the user updates their email or display name on the provider's side. Your app links this provider + provider_user_id pair to a local user account, and that link is what identifies them on future logins.
The email address the provider shares is useful metadata. It can seed the initial users.email when creating an account, and it can detect collisions when a user tries to sign up with a provider whose email is already in use. But email alone does not prove identity. Two providers can return the same email for different people, and a user can change their email on the provider at any time. The mixin treats email as bootstrap metadata, not as a key for linking.
The withSocialIdentity mixin provides low-level primitives for this linking model. It uses a dedicated social_identities table to store linked identities and their provider snapshot data (email, name, avatar). The key invariants are:
- No auto-linking by email. If a social identity's email matches an existing user, the mixin tells you about the collision. It does not silently link the identity or create a second user.
- Snapshot data stays separate. Provider fields like email, name, and avatar are stored on the
social_identitiesrow. They update on every login but never overwriteusers.email. - Works with Ally directly. The mixin accepts
AllyUserobjects from@adonisjs/ally, so you pass OAuth provider responses straight into the API without manual field mapping.
Setup
-
Install @adonisjs/ally
The social identity mixin works with
AllyUserobjects from@adonisjs/ally. Install it as a peer dependency if you have not already.node ace add @adonisjs/ally -
Create the social identities table
Generate a migration for the
social_identitiestable. This table stores linked provider identities with a unique constraint onprovider+provider_user_id.node ace make:migration create_social_identitiesdatabase/migrations/xxxx_create_social_identities.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'social_identities' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') table.integer('user_id').notNullable().unsigned() table.string('provider').notNullable() table.string('provider_user_id').notNullable() table.string('email').nullable() table.boolean('email_verified').notNullable().defaultTo(false) table.string('avatar_url').nullable() table.string('nickname').nullable() table.string('name').nullable() table.text('meta').nullable() table.timestamp('created_at', { precision: 6, useTz: true }).notNullable() table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable() table.unique(['provider', 'provider_user_id']) }) } async down() { this.schema.dropTable(this.tableName) } } -
Make the password column nullable
Social-only accounts will not have a password. Generate a migration to make the
passwordcolumn nullable on youruserstable.node ace make:migration make_password_nullable --alter=usersdatabase/migrations/xxxx_make_password_nullable.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'users' async up() { this.schema.alterTable(this.tableName, (table) => { table.string('password').nullable().alter() }) } } -
Apply the mixin
Apply the
withSocialIdentitymixin to your User model usingcompose.app/models/user.tsimport { UserSchema } from '#database/schema' import { compose } from '@adonisjs/core/helpers' import { withSocialIdentity } from '@adonisplus/persona/social' export default class User extends compose( UserSchema, withSocialIdentity() ) {}You can customize the table name by passing a config object.
app/models/user.tsexport default class User extends compose( UserSchema, withSocialIdentity({ table: 'custom_social_identities', }) ) {}
Social signup and login
Social authentication is a two-step round-trip. Your application redirects the user to the provider, the provider authenticates them, and then the provider redirects them back to a callback URL. Model this as two controller actions: a redirect action that starts the flow and a callback action that handles the response.
The callback uses User.resolveSocialIdentity to look up the incoming provider identity. The method dispatches to one of three branches: linked (returning user), link_required (email collision), or new (no identity and no collision). You can intercept any branch by passing the optional onLinked, onLinkRequired, and onNewUser hooks. The example below provides only onNewUser to create the user and return a custom status. The other branches fall through with their default events.
A single controller can serve both flows. When the user is already authenticated, the redirect action records their id in the session so the callback links the new provider to their existing account. Guests fall through to resolveSocialIdentity, which decides whether they are signing in or signing up.
import User from '#models/user'
import mail from '@adonisjs/mail/services/main'
import type { HttpContext } from '@adonisjs/core/http'
import AccountActivationProvision from '#mails/account_activation_provision'
export default class SocialAuthController {
async redirect({ ally, request, params, auth, session }: HttpContext) {
/**
* Logged-in users always start a link flow. The userId is verified
* again in the callback before attaching the provider account.
*/
if (auth.user) {
session.put('oauth_state', { userId: auth.user.id })
}
session.put('redirect.previousUrl', request.getPreviousUrl([]))
return ally.use(params.provider).redirect()
}
async callback({ ally, params, auth, session, response }: HttpContext) {
const provider = params.provider
const socialUser = await ally.use(provider).user()
const oauthState = session.pull('oauth_state')
/**
* A stored userId means an authenticated user is linking a provider.
*/
if (oauthState?.userId) {
const user = auth.getUserOrFail()
// Abort if the active session changed during the round-trip.
if (user.id !== oauthState.userId) {
session.flash('error', 'Unable to link social identity. Try again')
return response.redirect().back()
}
await user.linkSocialIdentity(provider, socialUser)
session.flash('success', `Your ${provider} account has been linked`)
return response.redirect().back()
}
/**
* Guest callback. Here, either we will have a new user signup,
* a returning user or an email collision.
*/
const result = await User.resolveSocialIdentity(provider, socialUser, {
onNewUser: async (trx, event) => {
const isEmailVerified = socialUser.emailVerificationState === 'verified'
// Create user and link their social identity
const user = await User.create(
{
fullName: socialUser.name ?? socialUser.nickName ?? null,
email: socialUser.email,
unverifiedEmail: isEmailVerified ? null : socialUser.email,
},
{ client: trx }
)
await user.linkSocialIdentity(provider, socialUser)
// Email was verified by the provider, hence it can be trusted
if (isEmailVerified) {
return { status: 'signup_verified' as const, user }
}
// Perform self verification
const token = await user.createEmailVerificationToken()
return { status: 'signup_unverified' as const, user, token }
},
})
switch (result.status) {
case 'linked':
case 'signup_verified':
await auth.use('web').login(result.user)
return response.redirect().withQs(false).toIntendedRoute('home')
case 'signup_unverified':
await auth.use('web').login(result.user)
await mail.sendLater(new AccountActivationProvision(result.user, result.token))
return response.redirect().toRoute('email_verifications.create')
case 'link_required':
session.flash(
'error',
'An account with this email already exists. Log in with your original provider and connect this one from your account settings.'
)
return response.redirect().back()
}
}
}
Register the two actions at paths that match the callbackUrl configured in config/ally.ts. The :provider segment lets a single pair of routes serve every Ally driver you have configured.
Do not place these routes inside a guest() middleware group. The same controller serves authenticated users who are linking an additional provider from their settings, and guest() would reject them.
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.post('oauth/:provider/redirect', [controllers.SocialAuth, 'redirect'])
router.get('oauth/:provider/callback', [controllers.SocialAuth, 'callback'])
Handling email collisions
When a user tries to sign in with GitHub but their email already belongs to a Google account, resolveSocialIdentity returns a result with status: 'link_required'. It does not auto-link the identity or create a second user row.
Your controller handles this status the same way it handles any other branch of the result. The minimal response is to flash an error and redirect, as shown in the main controller above. A friendlier pattern is to ask the user to log in with their original provider and then connect the new one from their account settings.
If you want to notify the existing account holder that someone attempted to sign in with their email, send a transactional email from the link_required branch before redirecting.
Connecting additional providers
Once a user is logged in, they can connect additional providers from their account settings. The unified controller from
Social signup and login already handles this case: when auth.user is set during the redirect action, the user's id is stored in the session under oauth_state. The callback reads it back, verifies it still matches the active session, and links the identity to the existing account using linkSocialIdentity.
Point your "Connect GitHub" button in the settings page at the same /oauth/github/redirect URL the login page uses. The controller decides which flow to run based on whether the user is already authenticated.
Verifying user.id === oauthState.userId in the callback protects against a subtle attack where the attacker starts a link flow and then tricks the victim into completing the provider consent while logged in as themselves. Without the check, the attacker's provider identity would be linked to the victim's account.
If the provider identity is already linked to a different user, linkSocialIdentity throws E_SOCIAL_IDENTITY_ALREADY_LINKED. This prevents one user from claiming another user's provider identity.
Re-syncing provider data
Provider data changes over time. A user might update their avatar on GitHub or change their display name on Google. To refresh the snapshot, run the user through the same OAuth flow and call linkSocialIdentity again.
The method is idempotent. When the identity is already linked to the same user, it updates all snapshot fields (email, name, nickname, avatar, meta) from the fresh AllyUser and returns. No separate "re-sync" method is needed.
export default class SettingsController {
async resyncProvider({ ally, auth, params, response }: HttpContext) {
const provider = params.provider
const socialUser = await ally.use(provider).user()
const user = auth.getUserOrFail()
// Same method as connecting — idempotent for existing links
await user.linkSocialIdentity(provider, socialUser)
return response.redirect('/settings/accounts')
}
}
Viewing linked accounts
To show which providers a user has connected, call listSocialIdentities. It returns an array of SocialIdentity objects with camelCase properties (provider, email, avatarUrl, etc.) that you can pass directly to your view.
The method returns an empty array if no identities are linked.
export default class SettingsController {
async linkedAccounts({ auth, inertia }: HttpContext) {
const user = auth.getUserOrFail()
const identities = await user.listSocialIdentities()
return inertia.render('settings/accounts', { identities })
}
}
Disconnecting a provider
Users may want to disconnect a provider from their account. Call unlinkSocialIdentity with the social identity row id to remove it. The id comes from the rows you list with listSocialIdentities.
The mixin does not prevent removing the last identity from a social-only account. If you allow unlinking, check in your controller that the user has another way to log in before proceeding.
export default class SettingsController {
async disconnectProvider({ auth, params, response }: HttpContext) {
const user = auth.getUserOrFail()
await user.unlinkSocialIdentity(params.id)
return response.redirect('/settings/accounts')
}
}
The matching route accepts the identity id as a route parameter:
router.delete('social-identities/:id', [controllers.SocialIdentities, 'destroy'])
Error handling
All exceptions thrown by the social identity mixin extend HttpResponseException, the same base class used by the email and password modules. Each exception includes a built-in handle method that content-negotiates the response:
- HTML requests: Flashes the error message to the session and redirects. You can customize the redirect URL by calling
error.setRedirectUrl('/path'). - JSON requests: Returns
{ errors: [{ message: "..." }] }with the appropriate status code. - JSONAPI requests: Returns
{ errors: [{ code: "E_...", title: "..." }] }. - i18n: If you have
@adonisjs/i18nconfigured, the error message is translated using theerrors.E_SOCIAL_*key.
E_SOCIAL_EMAIL_MISSING
Thrown by resolveSocialIdentity when the social user from the OAuth provider has no email address. Validate email presence in your controller before calling the method, or let the exception self-handle.
E_SOCIAL_USER_NOT_FOUND
Thrown by resolveSocialIdentity when a social identity row exists in the database but the associated user has been deleted. This is a data integrity issue and should not occur under normal operation.
E_SOCIAL_IDENTITY_ALREADY_LINKED
Thrown by linkSocialIdentity when you attempt to link a provider identity that is already linked to a different user. The identity belongs to someone else and cannot be transferred.
Instance methods
The withSocialIdentity mixin adds the following to model instances.
linkSocialIdentity
Links a social identity to the user. Idempotent for re-links (updates snapshot fields). Throws E_SOCIAL_IDENTITY_ALREADY_LINKED if the identity belongs to a different user.
await user.linkSocialIdentity('github', socialUser)
listSocialIdentities
Returns all linked SocialIdentity objects for the user, ordered by created_at descending. Returns an empty array if none exist.
const identities = await user.listSocialIdentities()
getSocialIdentity
Returns a single linked SocialIdentity row for the user by its primary key, or null if no row matches.
const identity = await user.getSocialIdentity(identityId)
unlinkSocialIdentity
Removes a linked identity by its primary key. Does nothing if no row matches the given id for this user.
await user.unlinkSocialIdentity(identityId)
Static methods
socialIdentities
The provider instance attached to the model. The mixin methods delegate to this provider internally, and you can reach for it directly when you need a lower-level operation. For example, User.socialIdentities.find(provider, providerUserId) looks up an identity row by its provider name and provider user id without going through resolveSocialIdentity.
resolveSocialIdentity
The main entry point for social login and signup. The method opens a transaction, looks up the provider identity, performs an email collision check, and dispatches to one of three branches.
The linked branch fires when a row in social_identities already matches the given provider and provider_user_id. The method refreshes the snapshot fields from the new socialUser and returns the existing user.
The link_required branch fires when no identity row matched, but a user with the same email already exists in the database. The method does not auto-link the new identity or create a second user. It surfaces the collision so your controller can refuse the sign-in and ask the user to authenticate via their original method first. This is a deliberate anti-takeover policy described in
Handling email collisions.
The new branch fires when no identity row matched and no email collision was found. Your application is expected to create the user and link the identity inside the open transaction.
You can intercept any branch by passing a hooks object as the third argument. Each hook receives the open trx and the typed event for that branch, runs inside the same transaction, and its return value becomes the resolved result. Branches without a hook fall through with their default event.
const result = await User.resolveSocialIdentity(provider, socialUser, {
onLinked: async (trx, event) => {
// event: { status: 'linked', user }
return { status: 'login' as const, user: event.user }
},
onNewUser: async (trx, event) => {
// event: { status: 'new' }
const user = await User.create({ email: socialUser.email }, { client: trx })
await user.linkSocialIdentity(provider, socialUser)
return { status: 'signup_verified' as const, user }
},
onLinkRequired: async (trx, event) => {
// event: { status: 'link_required', email, provider, user }
return { status: 'collision' as const, user: event.user }
},
})
All three hooks are optional. The most common pattern is to provide only onNewUser and let linked and link_required fall through with their default events. The controller then handles each status from the discriminated union.
const result = await User.resolveSocialIdentity(provider, socialUser, {
onNewUser: async (trx, event) => {
const user = await User.create({ email: socialUser.email }, { client: trx })
await user.linkSocialIdentity(provider, socialUser)
return { status: 'signup_verified' as const, user }
},
})
switch (result.status) {
// Default event from the linked branch, plus the custom signup status
case 'linked':
case 'signup_verified':
// Log the user in
break
// Default event from the link_required branch.
// event.email, event.provider and event.user are available here.
case 'link_required':
break
}
The method throws E_SOCIAL_EMAIL_MISSING when the social user has no email, and E_SOCIAL_USER_NOT_FOUND when an identity row points at a user that has been deleted.
SocialIdentity properties
Each SocialIdentity object returned by getSocialIdentities and the internal provider methods contains the following properties.
identifier
The primary key of the social identity row.
userId
The user this identity belongs to.
provider
The OAuth provider name (e.g. "google", "github").
providerUserId
The unique user identifier from the OAuth provider.
email
The email address from the provider. This is a snapshot, not the canonical app email.
emailVerified
Whether the provider considers the email verified.
nickname
The provider-specific nickname or username.
name
The display name from the provider.
avatarUrl
URL to the user's avatar from the provider.
meta
The original provider response body, parsed from JSON.
createdAt
When the identity was first linked.
updatedAt
When the snapshot was last updated.