Hooks
Run your own code when users sign up, sign in, verify their email, or change their password. Refuse sign-ups, edit new accounts, and sync users to other systems.
Hooks
Auth hooks let your app take part in sign-up and sign-in. Export them from your worker as auth, next to fetch, and Ploy Auth calls them for every sign-in method: password, sign-in links and codes, passkeys, and Google, Apple, and GitHub.
- Before hooks run before the action and can refuse it.
beforeSignUpcan also change the new account's metadata. - After hooks are notifications. They run once the action is done and never slow it down or make it fail.
import type { Database, PloyHandler } from "@meetploy/types";
interface Env {
DB: Database;
}
export default {
async fetch(request, env) {
return new Response("Hello!");
},
auth: {
beforeSignUp(event) {
if (!event.email.endsWith("@example.com")) {
return { allow: false, message: "Sign up with your work email." };
}
return { metadata: { ...event.metadata, plan: "free" } };
},
async afterEmailVerified(event, env) {
await addToNewsletter(event.user.email);
},
},
} satisfies PloyHandler<Env>;Hooks run in your deployment, with the same env bindings as fetch. Ploy calls them over its internal network; they are not reachable from the internet.
Turn hooks on
Turn on Auth hooks under Auth → Settings → Sign-ups and sessions. Only organization owners and admins can change it. While it is off, Ploy Auth never calls your worker for auth events. Each project's preview deployments call their own hooks.
ploy dev always runs the hooks your local worker exports, so you can try them before turning them on.
Hooks
| Hook | Runs | Event fields |
|---|---|---|
beforeSignUp | Before an account is created | email, metadata, method, provider |
beforeSignIn | Before an existing user gets a session | user, method, provider |
afterSignUp | After an account is created | user, method, provider |
afterSignIn | After a session is created, including the first one after sign-up | user, method, provider, newUser |
afterSignOut | After a user signs out | userId |
afterEmailVerified | Once an address is verified, including accounts created already verified | user |
afterPasswordReset | After a password is reset through the email link | user |
afterPasswordChange | After a signed-in user changes or adds a password | user, kind (changed or set) |
afterIdentityLinked | After a Google, Apple, or GitHub account is linked, including on sign-up | user, provider, email |
Every event also has an id, unique per event, an occurredAt timestamp, and the client's ip when it is known. user has the same shape as /me returns. method is one of password, oauth, magic_link, email_code, or passkey; provider is google, apple, or github for oauth and null otherwise.
Hooks receive (event, env, ctx), like the other handlers. Types for every event are exported from @meetploy/types.
When beforeSignUp runs
beforeSignUp runs right before the account is created, so it always sees the final email and metadata:
| Method | Runs when |
|---|---|
| Password | The sign-up request arrives |
| Google, Apple, GitHub | The provider sends the user back, for a new account |
| Sign-in link or code | The link or code is used, for an address without an account |
| Passkey | The passkey ceremony starts, before the browser prompt |
A sign-in link or code for a new address is still sent; refusing the sign-up in beforeSignUp stops the account from being created when it is used.
beforeSignIn does not run for the session that follows a sign-up, since beforeSignUp already approved it. For password and passkey sign-in, it only runs after the credential checked out, so it never sees failed attempts.
Refuse a sign-up or sign-in
Return { allow: false } from a before hook, optionally with a message for the user. Returning false works too. Returning nothing allows the action.
auth: {
async beforeSignUp(event, env) {
if (!event.ip) {
return;
}
await env.DB.prepare("INSERT INTO signup_attempts (ip) VALUES (?)")
.bind(event.ip)
.run();
const recent = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM signup_attempts WHERE ip = ? AND created_at > datetime('now', '-1 hour')",
)
.bind(event.ip)
.first<{ n: number }>();
if ((recent?.n ?? 0) > 5) {
return { allow: false, message: "Too many sign-ups. Try again later." };
}
},
async beforeSignIn(event, env) {
const suspension = await env.DB.prepare("SELECT user_id FROM suspended_users WHERE user_id = ?")
.bind(event.user.id)
.first();
if (suspension) {
return { allow: false, message: "This account is suspended." };
}
},
},Keep suspension, roles, and paid entitlements in server-owned tables, not user metadata. Metadata originates from sign-up input and is returned to the user; never store secrets there or treat it as trusted authorization state. The example assumes your app owns the signup_attempts and suspended_users tables.
A refused request fails with 403 and code signup_blocked or signin_blocked. JSON requests, SignInForm, and SignUpForm show your message. Redirect flows (social sign-in and sign-in links opened from email) add ?ploy_auth_error=signup_blocked or signin_blocked and show a generic message, because the error travels in the URL.
Change the new account
Return { metadata } from beforeSignUp to create the account with different metadata. It replaces the metadata entirely, so spread event.metadata to keep what the user sent. It must be an object of at most 1 KiB of UTF-8 JSON, or null. Hook responses are capped at 16 KiB; oversized or malformed before-hook responses fail closed.
beforeSignUp(event) {
return {
metadata: {
...event.metadata,
name: event.metadata?.name ?? event.email.split("@")[0],
signedUpWith: event.method,
},
};
},Timeouts and failures
Before hooks fail closed. If beforeSignUp or beforeSignIn throws, returns
something invalid, or does not answer within 5 seconds, the sign-up or sign-in
fails with 503 and code hook_failed, and the user sees "Sign-in is
temporarily unavailable." A check that silently passed while your code was
broken would let in exactly the users it is meant to stop.
After hooks start in the background after the corresponding database operation; the auth response does not wait for delivery. Each attempt gets 10 seconds; a hook that throws, times out, or cannot be reached is retried twice, after 2 and 10 seconds. Retries carry the same event id. Errors your hooks throw appear in the deployment's logs.
Delivery is best effort, not durable: events and retries are held in memory, not persisted in an outbox. A service restart can lose them, and delivery stops after the final failed attempt. Different events can run concurrently and arrive out of order. Do not use these notifications as the sole record for billing, access control, or required provisioning; reconcile against your auth database. For work you successfully receive, enqueue it in a queue for durable processing.
Use event.id as an idempotency key at the destination. A timeout cancels the HTTP call but cannot roll back work already performed by your hook, so a retry can overlap or repeat side effects. A before-hook timeout likewise does not undo its external side effects.
Hook events contain public user fields and event metadata, not passwords, password hashes, session cookies, or provider tokens. Do not put credentials in user metadata or in refusal messages. Refusal messages are limited to 300 characters and are visible to the user.
Keep before hooks fast: they run while the user waits. Move slow work, like calling a CRM, to an after hook or a queue.
Next.js
Add auth to the default handler export in the same ploy.ts that holds your queue and cron handlers:
import type { PloyHandler } from "@meetploy/types";
export default {
auth: {
async afterSignUp(event) {
console.log("New user", event.user.email);
},
},
} satisfies PloyHandler;@meetploy/start
Register the hooks with .auth():
import { ploy } from "@meetploy/start";
export default ploy<Env>()
.auth({
beforeSignUp: (event) =>
event.email.endsWith("@example.com") ? undefined : { allow: false },
})
.build();Self-hosted deployment prerequisites
Before enabling hooks, configure the internal auth service with HANDLER_URL pointing to the handler's trusted internal socket (port 8081 by default), not its public listener on 8080. Allow the internal service to reach that socket through your network policies. Never expose the trusted socket to public traffic or customer workers. The split Compose files supply this URL; separate deployments must provide it explicitly, otherwise the development fallback is http://localhost:8081.
For pod affinity, optionally set HANDLER_HEADLESS_HOST and HANDLER_INTERNAL_PORT on the internal service, and allow connections to the discovered handler pods. Set CUSTOM_DOMAIN consistently with the handler so server-side calls without a browser origin resolve the correct host. Missing connectivity makes before hooks fail closed with hook_failed; do not enable hooks until it is in place. See self-hosting.
What does not run hooks
- Users imported from another provider.
- Changes made directly to the auth tables in your database, including deleting users.
- Reading or caching an existing session, renewing its expiry, or synchronizing its cookies; see sessions. These invoke neither
beforeSignInnorafterSignIn. - Refreshing provider access tokens with
getProviderToken().
How is this guide?
Last updated on