YouSeemLegit
ReferenceQuickstart

From zero to signed in.

Five steps and one callback handler. The examples are Next.js App Router, but this is standard OAuth 2.0 with PKCE — any library that speaks it will work unchanged.

Create an account first Full reference
1

Register a client

2 min

Create an account, then add a client from your dashboard. Register the redirect URI your app will use — and the localhost variant too, since you will want it for development.

You get a client ID and a client secret. Put them somewhere safe now.

.env.local
# .env.local
YSL_CLIENT_ID=cmo0n1i31...
YSL_CLIENT_SECRET=cs_...
YSL_REDIRECT_URI=https://yourapp.com/api/callback

# Register the localhost variant too, then swap it in for dev:
# YSL_REDIRECT_URI=http://localhost:3000/api/callback

The client secret is a server-side credential. Never ship it to the browser, and never put it in a variable prefixed NEXT_PUBLIC_.

2

Brand the login page

3 min

In your dashboard, upload your logo and set your colors. The logo editor lets you crop, zoom and reposition, so a wide wordmark and a square icon both end up looking deliberate.

Open the live preview before writing any code — it is the real page your users will see, with your branding already applied.

On a white-label plan you can point your own subdomain at it, so the address bar reads login.yourcompany.com instead of ours.

3

Send people to authorize

2 min

Generate a state and a PKCE verifier, stash both in short-lived HttpOnly cookies, and redirect. The state is how you know the callback you receive is the one you started.

app/api/login/route.ts
// app/api/login/route.ts — start the flow
import { NextResponse } from 'next/server'
import { randomBytes, createHash } from 'crypto'

export async function GET() {
  const state = randomBytes(16).toString('base64url')
  const verifier = randomBytes(32).toString('base64url')
  const challenge = createHash('sha256').update(verifier).digest('base64url')

  const url = new URL('https://youseemlegit.com/api/oauth/authorize')
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('client_id', process.env.YSL_CLIENT_ID!)
  url.searchParams.set('redirect_uri', process.env.YSL_REDIRECT_URI!)
  url.searchParams.set('scope', 'openid profile email')
  url.searchParams.set('state', state)
  url.searchParams.set('code_challenge', challenge)
  url.searchParams.set('code_challenge_method', 'S256')

  const res = NextResponse.redirect(url)
  const opts = { httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 600, path: '/' }
  res.cookies.set('ysl_state', state, opts)
  res.cookies.set('ysl_verifier', verifier, opts)
  return res
}

PKCE is optional if you send a client secret, but there is no reason to skip it. With a challenge present, an intercepted authorization code is useless without the verifier.

4

Handle the callback

3 min

Verify the state, exchange the code at /api/oauth/token, and store the tokens in HttpOnly cookies. The token endpoint is standard OAuth — snake_case keys, form-encoded or JSON body, credentials in the body or as HTTP Basic.

app/api/callback/route.ts
// app/api/callback/route.ts — the only handler you write
import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get('code')
  const state = req.nextUrl.searchParams.get('state')
  const expected = req.cookies.get('ysl_state')?.value
  const verifier = req.cookies.get('ysl_verifier')?.value

  if (!code) return NextResponse.redirect(new URL('/?error=denied', req.url))
  if (!state || state !== expected) {
    return NextResponse.redirect(new URL('/?error=state', req.url))
  }

  // Standard OAuth token endpoint. snake_case keys, form-encoded body.
  const r = await fetch('https://youseemlegit.com/api/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.YSL_REDIRECT_URI!,
      client_id: process.env.YSL_CLIENT_ID!,
      client_secret: process.env.YSL_CLIENT_SECRET!,
      ...(verifier ? { code_verifier: verifier } : {}),
    }),
  })

  if (!r.ok) {
    const { error, error_description } = await r.json().catch(() => ({}))
    console.error('[ysl] token exchange failed', error, error_description)
    return NextResponse.redirect(new URL('/?error=exchange', req.url))
  }

  const { access_token, refresh_token } = await r.json()

  const res = NextResponse.redirect(new URL('/', req.url))
  res.cookies.delete('ysl_state')
  res.cookies.delete('ysl_verifier')
  res.cookies.set('ysl_access', access_token, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600, path: '/',
  })
  res.cookies.set('ysl_refresh', refresh_token, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, path: '/',
  })
  return res
}

If you get invalid_grant: redirect_uri mismatch, the URI in the exchange must match the one you used at authorize. Matching is on scheme, host and path — query parameters are free, the path must be exact.

5

Read the user, and keep them signed in

2 min

Profile fields live at the UserInfo endpoint. If all you need is “is this session still good”, the verify endpoint is cheaper and tells you when to rotate.

// lib/session.ts — read the current user
import { cookies } from 'next/headers'

export interface YslUser {
  sub: string
  email: string
  email_verified?: boolean
  name?: string
  given_name?: string
  family_name?: string
  picture?: string
}

// Profile fields come from the OIDC UserInfo endpoint.
export async function getUser(): Promise<YslUser | null> {
  const jar = await cookies()
  const token = jar.get('ysl_access')?.value
  if (!token) return null

  const res = await fetch('https://youseemlegit.com/api/oauth/userinfo', {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  })

  if (!res.ok) return null   // 401 means expired — refresh and retry
  return (await res.json()) as YslUser
}

Refresh rotates. The token you send is consumed and the response carries its replacement — write the new one before you respond to your user, or a crash in between will sign them out.

That's the integration.

From here you might want bulk import to bring an existing membership over, or the error and rate-limit tables for hardening your handler.

Bulk importError codesRate limitsGet help