YouSeemLegit

For developers

Authentication that behaves the way your library expects.

Authorization code flow with PKCE. Standard redirect, standard exchange, standard rotation. No SDK to install, no proprietary session format, no vendor object in your user table.

Get your client ID Quickstart

The flow

Four steps, end to end.

01

Redirect

Send the visitor to /login with your client_id, redirect_uri and state.

02

They sign in

On your domain, in your colors. Password, Google, LinkedIn, magic link, 2FA.

03

Exchange

We call your redirect_uri with a code. You POST it to /api/auth/token.

04

Verify

Verify the access token on each request; refresh it before it expires.

Your callback handler

Copy the one that matches your stack.

This is the only handler you have to write. It is a standard OAuth 2.0 code exchange — snake_case keys, form-encoded or JSON body, credentials in the body or as HTTP Basic. Any OAuth client library can do it for you instead.

Always check state

Any state you embed in the redirect URI comes back untouched — we set query params properly rather than concatenating them. Compare it before you trust the code.

// app/api/callback/route.ts
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')

  if (!code) return NextResponse.redirect(new URL('/?error=denied', req.url))
  if (state !== req.cookies.get('ysl_state')?.value) {
    return NextResponse.redirect(new URL('/?error=state', req.url))
  }

  // Standard OAuth 2.0 token endpoint. snake_case, form-encoded.
  const res = 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!,
      code_verifier: req.cookies.get('ysl_verifier')?.value ?? '',
    }),
  })

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

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

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

Endpoints

The whole surface area.

GET/api/oauth/authorize

Start the flow. response_type=code, client_id, redirect_uri, your state, and optional PKCE challenge.

POST/api/oauth/token

Both grants live here — authorization_code and refresh_token. HTTP Basic or body credentials; form-encoded or JSON.

GET/api/oauth/userinfo

Standard OIDC claims for a bearer token: sub, email, name, given_name, family_name, picture.

POST/api/auth/token/verify

Cheap session check. Returns valid, the user id and email, time remaining, and a shouldRefresh hint.

POST/api/auth/token/refresh

Legacy rotation endpoint. Accepts and returns both camelCase and snake_case keys.

POST/api/admin/users/import

Bulk-create up to 1000 users per batch. Returns 207 with per-row errors on partial success.

GET/login

The hosted, fully branded login page. Users reach it via the authorize endpoint.

Full request and response shapes, error codes and rate limits are in the documentation.

Check a request two ways

Use the verify endpoint as a cheap per-request guard, and the OIDC UserInfo endpoint when you actually need names and avatars. Verify also tells you when the token is close to expiring.

lib/auth.ts
// Two ways to check a request, depending on what you need.

// (a) Cheap guard — is this session still live?
//     Note: the key is "token", and an invalid token
//     returns 200 with valid:false, not a 401.
export async function checkSession(token: string) {
  const res = await fetch('https://youseemlegit.com/api/auth/token/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
    cache: 'no-store',
  })
  const d = await res.json()
  return d.valid ? { userId: d.user.id, email: d.user.email, shouldRefresh: d.shouldRefresh } : null
}

// (b) Full profile — standard OIDC UserInfo.
export async function getProfile(token: string) {
  const res = await fetch('https://youseemlegit.com/api/oauth/userinfo', {
    headers: { Authorization: 'Bearer ' + token },
    cache: 'no-store',
  })
  if (!res.ok) return null
  return await res.json()   // sub, email, name, given_name, family_name, picture
}

Stay in sync with webhooks

User created, email verified, password changed, session revoked — delivered as signed POSTs so your database doesn't drift and you never have to poll.

lib/webhook.ts
// Verify a signed webhook before trusting it
import { createHmac, timingSafeEqual } from 'crypto'

export function verifySignature(rawBody: string, header: string) {
  const expected = createHmac('sha256', process.env.YSL_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(header)
  return a.length === b.length && timingSafeEqual(a, b)
}

// Always read the RAW body — parsing first will break the signature.

Migrating in

Cut over behind a flag, not over a weekend.

Import your existing users, wire the new flow behind an environment flag, and test it against real accounts while your current provider keeps serving traffic. Flip the flag when you're satisfied — flip it back if you're not.

Bulk import
Up to 1000 users per batch, de-duplicated, with per-row errors instead of a failed job.
No password resets
Members keep signing in the way they always have.
Reversible
The flag points at either provider until you remove it.
Talk through a migration

Get a client ID in two minutes.

Create an account, register a redirect URI, and start redirecting.

Start free Read the docs