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/introspectwithtoken=<jwt>. It recognizes password-flow login tokens and returns{ "active": true, ... }withsub,tid,tenant_id,email, androles.
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:
- Client-side — discard the stored access token and refresh token.
- 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
}