01.software Docs

Customers

Complete hosted sign-in, current-customer readback and logout in your app.

Customer emails are saved in lowercase. API writes remove surrounding whitespace; the Admin Panel rejects surrounding whitespace during form validation. Each workspace reserves an email for one customer, including archived customers; letter case does not create a new account.

When a customer withdraws, their posts and comments remain. Account erasure replaces the copied author name with [삭제된 사용자] and removes the handle and avatar. We recommend explaining this content-retention and author-erasure behavior in your app's privacy policy.

Account handles

Use an optional account handle separately from the display name. Profile reads and customer profile updates retain the handle string/null shape. Setting a handle keeps the profile's existing visibility; handle sign-in is unavailable.

ActionRule
Customer changes or clears a handleUp to twice in a rolling 14 days.
Customer reclaims a former handleOnly the original customer can reclaim it for 14 days.
Account erasureThe current handle is blocked for everyone for at least 14 days; expiry is rounded up to a UTC day boundary.

A conflict does not reveal the owner or reservation reason. The customer and an authorized workspace administrator can change the handle. Editors and server keys cannot change it. Administrators use the by-ID API; bulk handle edits return 400. Suspended or archived accounts allow only clearing the handle. The Admin Panel profile handle input is read-only; administrator changes are API-only.

Prepare

Enable customers and get a Publishable Key for the intended workspace and environment. Ask its operator to register a hosted client:

  • an enabled clientId;
  • the exact normalized HTTPS app origin;
  • the callback URI APP_ORIGIN/api/account/callback;
  • the logout URI APP_ORIGIN/account, because this recipe sends postLogoutRedirectUri. It is needed only when you send it.

Wildcards and loopback hosts are not accepted. Browser API origins are registered separately. Use an HTTPS deployment or HTTPS development origin.

The issuer is https://01.software. Live and Sandbox workspaces share it; the Publishable Key and clientId identify the workspace. The issuer is not a per-client value, and it stays the same when you develop the app locally.

Copy this into a Next.js App Router app with the @/* alias rooted at the project directory, and install server-only (npm install server-only@0.0.1). The examples run on each request; Build Options lists the tested versions and setup.

# .env.local (configure the same values in the deployed app)
NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY=pk01_...
SOFTWARE_API_URL=https://your-console-origin.example
CUSTOMER_OAUTH_CLIENT_ID=your-registered-client-id
CUSTOMER_OAUTH_ISSUER=https://01.software
APP_ORIGIN=https://your-app.example

The API URL must be the value your operator confirmed. Never put an operator PAT, Secret Key, signing secret, or internal exchange secret in this customer app.

Implement hosted sign-in

Copy all three files. Each request creates its own non-persisted SDK client. The callback consumes the state and verifier cookies, checks state, exchanges the PKCE-bound code once, and reads the identity with me(). The access and refresh tokens stay in Secure HttpOnly host cookies, never in browser storage or URLs. The SDK does not verify idToken; treat it as opaque.

// lib/customer.ts
import 'server-only'
import { createClient } from '@01.software/sdk'
import { cookies } from 'next/headers'

export const accessCookie = '__Host-customer-access'
export const refreshCookie = '__Host-customer-refresh'
export const cookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: 'lax' as const,
  path: '/',
}

export function customerClient(token?: string) {
  return createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: {
      persist: false,
      token,
      oauth: {
        clientId: process.env.CUSTOMER_OAUTH_CLIENT_ID!,
        issuer: process.env.CUSTOMER_OAUTH_ISSUER!,
      },
    },
  })
}

export async function currentCustomer() {
  const jar = await cookies()
  const client = customerClient(jar.get(accessCookie)?.value)
  const customer = await client.customer.auth.me()
  return { client, customer, hadToken: Boolean(jar.get(accessCookie)?.value) }
}
// app/api/account/[action]/route.ts
import { cookies } from 'next/headers'
import {
  accessCookie,
  refreshCookie,
  cookieOptions,
  customerClient,
} from '@/lib/customer'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type Context = { params: Promise<{ action: string }> }
const stateCookie = '__Host-customer-state'
const verifierCookie = '__Host-customer-verifier'

function appOrigin() {
  const url = new URL(process.env.APP_ORIGIN!)
  if (url.protocol !== 'https:' || url.origin !== process.env.APP_ORIGIN) {
    throw new Error('APP_ORIGIN must be an exact HTTPS origin')
  }
  return url.origin
}

async function handle(request: Request, context: Context) {
  const { action } = await context.params
  const jar = await cookies()
  const origin = appOrigin()
  const reply = (body: object, status = 200) =>
    Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } })
  const clearSession = () => {
    for (const name of [accessCookie, refreshCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const clearFlow = () => {
    for (const name of [stateCookie, verifierCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const redirect = (path: string) =>
    new Response(null, {
      status: 303,
      headers: { Location: origin + path, 'Cache-Control': 'no-store' },
    })
  const client = customerClient(jar.get(accessCookie)?.value)
  const oauth = client.customer.oauth!
  const redirectUri = origin + '/api/account/callback'
  if (request.method === 'POST' && request.headers.get('origin') !== origin) {
    return reply({ error: 'origin_not_allowed' }, 403)
  }
  try {
    if (request.method === 'GET' && action === 'login') {
      const flow = await oauth.createAuthorizationUrl({ redirectUri })
      jar.set(stateCookie, flow.state, { ...cookieOptions, maxAge: 600 })
      jar.set(verifierCookie, flow.codeVerifier, {
        ...cookieOptions,
        maxAge: 600,
      })
      return new Response(null, {
        status: 303,
        headers: {
          Location: flow.authorizationUrl,
          'Cache-Control': 'no-store',
        },
      })
    }
    if (request.method === 'GET' && action === 'callback') {
      const expectedState = jar.get(stateCookie)?.value
      const codeVerifier = jar.get(verifierCookie)?.value
      clearFlow()
      if (!expectedState || !codeVerifier)
        return reply({ error: 'flow_expired' }, 400)
      const { code } = oauth.parseCallback(request.url, { expectedState })
      const tokens = await oauth.exchangeCode({
        code,
        codeVerifier,
        redirectUri,
      })
      // Read identity from me(); the SDK does not verify idToken.
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'logout') {
      const refreshToken = jar.get(refreshCookie)?.value
      clearSession()
      clearFlow()
      if (!refreshToken) return reply({ error: 'revocation_unconfirmed' }, 409)
      await oauth.logout({
        refreshToken,
        postLogoutRedirectUri: origin + '/account',
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'refresh') {
      const refreshToken = jar.get(refreshCookie)?.value
      if (!refreshToken) return reply({ error: 'sign_in_required' }, 401)
      const tokens = await oauth.refresh({ refreshToken })
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    return reply({ error: 'not_found' }, 404)
  } catch {
    // Do not return tokens, callback query strings, or raw upstream errors.
    if (action === 'callback' || action === 'refresh') clearSession()
    return reply(
      {
        error:
          action === 'logout'
            ? 'revocation_unconfirmed'
            : 'authentication_failed',
      },
      502,
    )
  }
}

export const GET = handle
export const POST = handle
// app/account/page.tsx
import { cookies } from 'next/headers'
import { currentCustomer, refreshCookie } from '@/lib/customer'

export const dynamic = 'force-dynamic'

export default async function AccountPage() {
  const { customer } = await currentCustomer()
  const canRefresh = Boolean((await cookies()).get(refreshCookie)?.value)
  if (!customer && !canRefresh) return <a href="/api/account/login">Sign in</a>
  return (
    <main>
      <h1>
        {customer ? `Welcome, ${customer.name}` : 'Session needs refresh'}
      </h1>
      <form method="post" action="/api/account/refresh">
        <button>Refresh session</button>
      </form>
      <form method="post" action="/api/account/logout">
        <button>Sign out</button>
      </form>
    </main>
  )
}

Normal, empty, and failure results

Open /account, sign in at the registered issuer, and return to /account. The displayed name comes from me().

SituationPage
No access token, or me() returns 401, and no refresh cookieShows Sign in.
A refresh cookie remains, including after the 900-second access cookie expiresKeeps Refresh session and Sign out available.
Feature, workspace, or network errorPropagates to the app error boundary; never shown as a signed-out success.

Add your normal Next.js error.tsx retry UI without exposing upstream error bodies.

Refresh. POST Refresh session rotates the client-bound refresh token, and works after the access token expires while the refresh token is valid. A successful refresh redirects to /account. Serialize refresh requests per session; never replay an old token or retry the token exchange automatically. This cookie example supports one login flow and one refresh at a time per browser, and starting a second login replaces the flow cookies. For refresh across several tabs, use a shared server session with serialization instead of separate cookie copies.

Sign out. Sign out clears the app cookies and asks the hosted service to revoke the session family.

  • SDK success means the hosted service accepted the request. Whether it swept sessions is not shown to the app.
  • It sweeps when it recognizes the submitted refresh token as unexpired and issued to this client in this workspace, even if the token was already rotated or its family already revoked. A missing, expired, or other-client token does not trigger a sweep, and the app cannot tell these cases apart.
  • A sweep covers that customer's sessions across all clients in the workspace, including legacy-direct sessions; see Customers SDK: Hosted session methods.
  • Removing cookies alone does not revoke anything. If the request fails or the refresh cookie is missing, the response is revocation_unconfirmed: the app is signed out locally, but server revocation is unconfirmed. Do not claim server revocation succeeded or retry with guessed credentials. Ask the customer to sign in again, or use your account support flow.

Test missing or expired state cookies, mismatched state, callback replay, a bad PKCE verifier, a disabled or wrong client, an unregistered origin, redirect, or logout URI, an expired or reused refresh token, and other-workspace credentials. Token, identity, and app session responses must be no-store. The recipe's same-origin POST check protects its cookie-based refresh and logout routes; Next.js Server Actions check origins themselves. Keep callback query strings, tokens, personal data, and raw errors out of access logs, analytics, screenshots, and client responses.

Test in a Sandbox

A Sandbox workspace sends customer sign-in email only to the own address of a member of that Sandbox. To test email codes, create the test customer with a member address whose current email is verified.

Sign-in methodIn a Sandbox
PasswordYou can test it.
Google, KakaoYou can test providers with a configured shared development app after enabling them on the client.
Email codeYou can test it with a customer at a member's own address. The mail uses the platform sender and a [Sandbox] subject. No code reaches any other address, and the start request returns the same response.

For Sandbox limits on password reset, see Complete an existing local-password reset.

Existing apps: direct legacy compatibility

Keep this path only for an existing app that already uses direct sign-in, never as a workaround for a missing hosted client. Supply test inputs privately; the example returns booleans, not customer data.

// Existing direct-auth applications only; call from a trusted server.
import { createClient } from '@01.software/sdk'

export async function legacySession(email: string, password: string) {
  const client = createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: { persist: false },
  })
  await client.customer.auth.login({ email, password })
  const errors: unknown[] = []
  let signedIn = false
  try {
    signedIn = (await client.customer.auth.me()) !== null
  } catch (error) {
    errors.push(error)
  }
  try {
    await client.customer.auth.logoutAsync()
  } catch (error) {
    errors.push(error)
  }
  try {
    client.customer.auth.logout()
  } catch (error) {
    errors.push(error)
  }
  if (errors.length === 1) throw errors[0]
  if (errors.length > 1) {
    throw new AggregateError(errors, 'Customer session and cleanup failed', {
      cause: errors[0],
    })
  }
  return { signedIn, revocationConfirmed: true }
}

After login succeeds, the identity lookup and both cleanup steps run in order. A single error is rethrown unchanged. If cleanup also fails, AggregateError.cause and errors[0] keep the original identity error, and later entries keep the cleanup failures. Inspect these errors only on your trusted server; never return raw errors or credentials to the browser.

logoutAsync() revokes only the Publishable Key workspace's legacy-direct refresh family, including its rotated successor. It does not immediately revoke access JWTs that were already issued. It clears the local token pair even if the server call fails, and reports that failure. Synchronous logout() is local only.

Hosted and direct logout have different effects. See the deployment guide for the Community Forum starter's logout flow.

Check customer authentication status in Console

The Authentication status panel in Customer detail is read-only. A customer manually registered without a password shows No password. This message describes only whether a password is set.

Process a redemption coupon in Console

Select the Workspace explicitly, open the Customer detail, and find Redemption coupons. The panel shows up to 10 coupons, available ones first.

  • tenant-admin and tenant-editor: choose Redeem for an available coupon.
  • tenant-admin: choose Void, select a reason, and confirm.
  • tenant-viewer: view only. A super-admin must also select a Workspace explicitly.

Check the Customer and coupon before confirming: redeem and void cannot be undone, and a correction needs a newly issued coupon. If a response is lost, retry in the same confirmation to recover the recorded result without applying the command twice. A role or Workspace change can deny the command even while the button is visible; refresh the page after a denial.

Redeem needs both Ecommerce and Customers. Turning Customers off hides Customer detail and this panel, while server reads, Customer self-reads, and server void stay available. Issuing and staff apps use the server coupon channel.

Optional handle password login

A workspace administrator can enable ID sign-in in Console under Settings → Workspace Access → Customer sign-in. The server setting is customerAuth.handleLoginEnabled. It is off by default. The setting is for the current account handle, not a separate private login ID. Enable it after the handle migration and server rollout finish.

Existing direct email/password requests keep working. With opt-in, direct client.customer.auth.login({ handle, password }) accepts one handle instead of email; sending both or neither is a validation error. CustomerLoginData keeps its existing email interface, and CustomerPasswordLoginData adds the handle branch. Handle login requires a stored email and local password; accounts without a stored email cannot use it. Turning it off leaves email login, public handles and existing sessions intact.

Hosted OAuth remains the recommended integration for new storefronts. For enabled workspaces, select Sign in with email or ID on the hosted screen to enter an email or current handle and password. Passwordless codes and password reset remain email-based. A renamed or cleared handle stops matching; the old handle is not a login alias. Login errors and lockout rules stay the same for email and handle.

Next Actions

Use this hosted session for Community and Events participation. Review Authentication & Keys and Deploy for origin, credential, and deployment checks.

On this page