Authentication
lk-wiz uses AWS Cognito as its identity provider, supporting both Google social login and email/password, with reCAPTCHA on signup and a mandatory admin-approval gate.
Cognito user pool
A single Cognito user pool backs both the web app and the MCP server. It is configured with:
- Google as a social identity provider.
- Email/password with reCAPTCHA Enterprise protection on registration.
- Lambda triggers that customize the sign-up and token lifecycle.
Lambda triggers
| Trigger | Purpose |
|---|---|
| Pre-signup | Auto-verifies social (Google) signups; email/password signups follow the standard verification flow. |
| Post-confirmation | Provisions the user in DynamoDB and notifies admins via Slack that a new user is pending approval. |
| Pre-token-generation | Injects email, custom:role, and custom:approved claims into both the ID and access tokens. It only stamps the current approval state onto the token — it does not block issuance, so an unapproved user still receives a fully valid (signed, non-expired) token. |
Approval gate
New users exist in Cognito immediately but are marked approved: false in DynamoDB until a
platform admin approves them. The pre-token-generation trigger reflects this state into the
custom:approved claim on each token.
Known gap / security note. The
custom:approvedapproval gate is not enforced on the REST API. See Approval is not enforced on REST below — an authenticated-but-unapproved user can call/api/*REST routes today.
JWT claims & authorization
Access tokens carry the user's identity (sub, email) plus approval (custom:approved)
and role (custom:role) information.
The deployed API Gateway authorizer is a native Amazon Cognito JWT authorizer
(aws_apigatewayv2_authorizer.lkwiz_jwt, authorizer_type = "JWT", wired to every
require_auth = true route). On each request it validates only:
- the token signature (against the Cognito JWKS),
- the issuer (
iss) — the configured Cognito user pool, and - the audience (
aud) — the web app client ID.
It does not inspect custom claims, so it cannot evaluate custom:approved. A valid,
non-expired token from any user in the pool — approved or not — passes it. The forwarded
claims are then read by the domain handlers, which apply workspace-level RBAC
(owner/editor/viewer) before returning data.
The same token model is reused by the MCP server; see the MCP authentication docs.
Approval is not enforced on REST
Security note — read this before assuming REST is approval-gated.
The REST API is protected by the native Cognito JWT authorizer described above, which
validates signature/issuer/audience only. It does not check custom:approved.
Consequently, an authenticated-but-unapproved user can call /api/* REST routes today.
A Lambda approval-authorizer (authorizer_handler → authorizer_service.authorize) that
does enforce custom:approved == "true" exists in the codebase, but it is built and
unwired: the function is declared in the Lambda handler map, yet no
aws_apigatewayv2_authorizer resource references it, so it is attached to zero routes.
Approval is currently enforced only in two places:
- WebSocket collaboration tickets —
collab_service._verify_ticket_validrejects a$connectwhose ticket was minted withapproved != "true". - Admin operations —
admin_servicegates admin-only actions in its own service layer.
The frontend also hides the app from unapproved users client-side (the "Awaiting Approval"
screen decodes custom:approved from the token), but that is a UX affordance, not a
server-side security control.