Olympus Docs
CookbookDefensive security

Handle suspended accounts gracefully

User attempts to use a suspended account, what happens

Accounts get suspended: ToS violation, fraud detection, ATO response, admin action. The user might not know. They try to sign in or use the app. What you do matters.

States

In Olympus, identity state can be:

  • active: normal.
  • inactive: suspended / deprovisioned. Can't sign in.

For more granularity, use metadata:

identity.metadata_admin.suspension_reason = "tos_violation" | "fraud_suspected" | "user_requested" | ...
identity.metadata_admin.suspension_date = "2026-05-13T..."
identity.metadata_admin.suspension_until = "2026-08-13T..." // optional

Sign-in attempts

// Custom Kratos login hook
export async function POST(req: Request) {
  const { identity } = await req.json();
  if (identity.state === "inactive") {
    return Response.json({
      reject: true,
      error: "account_suspended",
      message: getMessageForReason(identity.metadata_admin?.suspension_reason),
    });
  }
  return Response.json({ ok: true });
}

Hera's UI shows the message.

Messages

Different reasons → different messages:

function getMessageForReason(reason: string) {
  switch (reason) {
    case "tos_violation":
      return "Your account has been suspended for violating our Terms of Service. Contact support@your-domain.com if you believe this is an error.";
    
    case "fraud_suspected":
      return "Your account has been temporarily restricted. Please contact support to verify your identity.";
    
    case "user_requested":
      return "Your account is in the process of being deleted. If you'd like to restore it, click below.";
    
    case "subscription_lapsed":
      return "Your subscription has expired. Renew to continue.";
    
    default:
      return "Your account is currently restricted. Please contact support.";
  }
}

Honest, actionable, no security details.

Reactivation paths

Some reasons have self-service reactivation:

{reason === "user_requested" && (
  <Button onClick={restoreAccount}>Restore my account</Button>
)}

{reason === "subscription_lapsed" && (
  <Button onClick={renewSubscription}>Renew subscription</Button>
)}

{reason === "tos_violation" && (
  <a href="mailto:support@your-domain.com">Contact support</a>
)}

Match path to reason.

Behavior during suspension

When suspended user tries to use app:

async function middleware(req) {
  const session = await getSession(req);
  if (session?.identity.state === "inactive") {
    // No app access. Force suspension page.
    return redirect("/account-suspended");
  }
}

Hard block. Even existing sessions of suspended users can't use API.

If session was created before suspension: revoke on suspension:

async function suspend(identityId, reason) {
  await kratos.adminPatch(identityId, [
    { op: "replace", path: "/state", value: "inactive" },
    { op: "add", path: "/metadata_admin/suspension_reason", value: reason }
  ]);
  await kratos.adminRevokeSessions({ identity_id: identityId });
}

Email at suspension

Subject: Your account has been suspended

Hi,

Your account has been suspended for the following reason:
  [Plain-text explanation]

What this means:
- You can't sign in to [Your App].
- Your data is preserved.
- You can appeal at: support@your-domain.com

Suspension since: [Date]
[Suspension ends: [Date], if temporary]

Don't auto-explain detection methods (helps attackers bypass).

Temporary vs permanent

const isTemporary = identity.metadata_admin?.suspension_until;
if (isTemporary && new Date(identity.metadata_admin.suspension_until) < new Date()) {
  // Suspension ended. Auto-reactivate.
  await kratos.adminPatch(identityId, [
    { op: "replace", path: "/state", value: "active" }
  ]);
}

Daily cron checks expired suspensions.

Audit

INSERT INTO security_audit (event_type, target_id, actor_id, metadata)
VALUES (
  'account_suspended',
  $user_id,
  $admin_or_system_id,
  '{"reason": "$reason", "until": "$until"}'
);

Trail of suspensions. For audit + dispute resolution.

Appeals

User appeals: "I didn't violate ToS." Build a flow:

1. User clicks "Appeal suspension" → form.
2. Form asks for context.
3. Submitted → ticket in your support tool.
4. Human reviews.
5. Decision: reinstate or uphold suspension.

Don't let appeals be a back-door reactivation. Genuine human review.

Self-suspension

Some apps let user self-suspend ("Take a break", Twitter/X had this):

<Button onClick={selfSuspend}>Pause my account for 30 days</Button>

Sets state=inactive with reason=user_requested. After 30 days: auto-reactivate.

Lighter UX than full account deletion.

Suspension lifecycle

active → suspended (with reason)
suspended → active (reactivation, appeal, or timeout)
suspended → deleted (after long timeout, user request, or hard delete)

Document the lifecycle in your moderation policy.

Permissions during suspension

What can a suspended user still do?

  • ✗ Sign in.
  • ✗ API access.
  • ✗ Resource read/write.
  • ✓ Read suspension reason / appeal.
  • ✓ Account export (DSR).
  • ✓ Pay overdue invoices.

Selectively allow. Most: blocked.

Multi-tenant complication

In multi-tenant, suspension could be:

  • Global (across all tenants).
  • Per-tenant (suspended from Acme but active in BigCorp).

For typical Olympus: global. For complex SaaS: per-tenant.

On this page