Authentication Endpoints

Endpoints for user registration, login, passwordless sign-in, password management, email verification, and social OAuth.

Most authentication endpoints require the X-Bulwark-Tenant header. You may also include the optional X-Bulwark-App-Id header to associate the authentication event with a specific application. The one bootstrap exception is Identify App, which resolves the tenant from a publishable key.

Passwordless sign-in (magic link, email OTP) and the second-factor / passkey endpoints live on the MFA & Passkeys page; the passwordless senders are also summarized below.


Identify App

POST /api/v1/auth/identify

Resolves a publishable key to its tenant + enabled auth methods. Called by the SDKs on init to discover what login options to render. Public — no tenant header required.

Body

{
  "publishable_key": "pk_live_..."
}

Response 200

{
  "tenant_id": "ten_01j...",
  "tenant_slug": "acme-prod",
  "display_name": "Acme",
  "environment": "production",
  "branding": {},
  "auth_methods": {
    "password": true,
    "passkeys": true,
    "google": true,
    "github": false,
    "mfa_policy": "optional"
  }
}

Returns 401 if the publishable key is invalid.


Register

POST /api/v1/auth/register

Body

{
  "email": "[email protected]",
  "password": "supersecret",
  "display_name": "Jane Doe"
}

Response 201

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900
}

register adds a user to an existing tenant (resolved from the publishable key or X-Bulwark-Tenant). To create a brand-new workspace, use Sign Up.

Existing-email behavior (anti-enumeration)

To prevent account enumeration, register never returns 409. For an already-registered email it still returns 201, but with a generic body and no tokens:

{ "status": "check your email to complete registration", "user_id": "" }

A brand-new registration returns user_id and access_token. So a public client distinguishes the two cases by the presence of access_token — not by status code. Note one nuance: access_token is also withheld (with verification_required: true) when the tenant requires email verification, so "no token" alone doesn't imply "already existed."

If you need a clean 409 on duplicates, use the authenticated admin endpoint POST /api/v1/admin/users — an admin context is not an enumeration vector, so it returns 409 Conflict for an existing email.


Sign Up

POST /api/v1/auth/signup

Self-service onboarding: creates a new organization + tenant (workspace) and an admin user who owns it. Public. (Contrast with Register, which adds a user to an existing tenant.)

Body

{
  "email": "[email protected]",
  "password": "supersecret",
  "display_name": "Ada Lovelace",
  "company_name": "Acme"
}

company_name is required; display_name is optional.

Response 201

{
  "user_id": "usr_01j...",
  "tenant_id": "ten_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "email_verified": false
}

If the email already exists, the response is still 201 with a generic "check your email" message (to prevent account enumeration). Can be disabled per deployment.


Login

POST /api/v1/auth/login

Body

{
  "email": "[email protected]",
  "password": "supersecret"
}

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "email_verified": false,
  "mfa_required": false
}

Access token claims & verification

The access_token is a signed EdDSA (Ed25519) JWT. Its claims include:

| Claim | Meaning | | --- | --- | | sub | User ID | | tid | Home tenant (workspace) ID — canonical | | tenant_id | Duplicate of tid for SDK convenience | | org_id | Active organization (when applicable) | | email | User email — always present on login-flow tokens | | roles | Role slugs | | sid | Login session ID | | exp, iat, iss, aud | Standard registered claims (iss: "bulwark") |

To validate a token server-side, either:

  • Verify the signature locally against the JWKS at /.well-known/jwks.json (alg: EdDSA, kty: OKP). This is the recommended path for a resource server — no network round-trip per request. Do not trust an unverified local decode.
  • Call introspection: POST /oauth2/introspect with token=<jwt>. It recognizes password-flow login tokens and returns { "active": true, ... } with sub, tid, tenant_id, email, and roles.

GET /oauth2/userinfo is part of the OAuth2/OIDC flow and only accepts tokens issued through the OAuth2 authorization-code path — not password-login tokens. For login-flow tokens, read email directly from the JWT claim above.


Refresh Token

POST /api/v1/auth/refresh

Body

{
  "refresh_token": "rt_..."
}

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_new_...",
  "expires_in": 900,
  "email_verified": true
}

Logout

There is no dedicated /auth/logout endpoint. Access tokens are short-lived JWTs that are not server-side revocable on their own, so logout is performed in two parts:

  1. Client-side — discard the stored access token and refresh token.
  2. Server-side (optional but recommended) — revoke the underlying login session so the refresh token can no longer mint new access tokens.

Revoke the current device's session with the sessions API:

DELETE /api/v1/me/sessions/{id}

To revoke every session for the current user (sign out everywhere):

DELETE /api/v1/me/sessions

Forgot Password

POST /api/v1/auth/forgot-password

Body

{
  "email": "[email protected]"
}

Response 200

{
  "message": "if that email exists, a reset link has been sent"
}

Reset Password

POST /api/v1/auth/reset-password

Body

{
  "token": "reset_...",
  "new_password": "newpassword"
}

Response 200

{
  "message": "password reset successfully"
}

Passwordless — Magic Link

POST /api/v1/auth/magic-link

Emails the user a one-time login link. The token is delivered by email only (in production); verify it with the endpoint below.

Body

{
  "email": "[email protected]"
}

Response 200

{
  "challenge_id": "chl_01j...",
  "status": "sent",
  "message": "Magic link sent to the provided email address"
}

Verify

POST /api/v1/auth/magic-link/verify
{
  "token": "<token-from-the-emailed-link>"
}

Returns an access token on success. Note: passwordless verification returns an access_token only — no refresh token / session (unlike password and passkey login).

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "expires_in": 900
}

Errors: 401 invalid link, 410 expired.


Passwordless — Email OTP

POST /api/v1/auth/otp/send

Emails the user a one-time verification code.

Body

{
  "email": "[email protected]"
}

Response 200

{
  "challenge_id": "chl_01j...",
  "status": "sent",
  "message": "Verification code sent to the provided email address"
}

Verify

POST /api/v1/auth/otp/verify
{
  "code": "123456"
}
{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "expires_in": 900
}

Errors: 401 invalid code, 410 expired, 429 too many attempts. Access token only — no refresh token.


Verify Email

POST /api/v1/auth/verify-email

Confirms a user's email address using the token from the verification email.

Body

{
  "token": "verify_..."
}

Response 200

{
  "status": "email verified"
}

A GET variant accepts the token as a query parameter, so a plain link in an email works without a form:

GET /api/v1/auth/verify-email?token=verify_...

Both return 422 if the token is invalid, expired, or already used.


Resend Verification

POST /api/v1/auth/resend-verification

Sends a fresh verification email to the logged-in user. No-op if already verified.

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "status": "verification email sent"
}

Current User

GET /api/v1/auth/me

Returns the authenticated user plus their tenant and the sibling applications in the workspace. (For just the user's editable profile, use GET /api/v1/user/profile.)

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "user": {
    "id": "usr_01j...",
    "email": "[email protected]",
    "display_name": "Jane Doe",
    "roles": ["user"]
  },
  "tenant": {
    "id": "ten_01j...",
    "name": "Acme",
    "slug": "acme-prod",
    "display_name": "Acme",
    "environment": "production",
    "settings": {}
  },
  "workspace": {
    "id": "wsp_01j...",
    "applications": [
      { "id": "ten_01j...", "name": "Acme", "environment": "production" }
    ]
  }
}

Social OAuth — Redirect

GET /api/v1/auth/social/{provider}/authorize

Supported providers: google, github, microsoft

Redirects the user to the provider's OAuth consent page.

Query Parameters

  • tenant_id — Required. The tenant to authenticate against.
  • redirect_uri — Optional. URI to redirect the user to after a successful login (token appended as #token=). Must be in the tenant's allowed redirect URI list.

Social OAuth — Callback

GET /api/v1/auth/social/{provider}/callback

Called by the provider after consent. Returns tokens.

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "is_new_user": false
}