agentleFS
fatonhfatonh/symfony-skills/skills/jwt-authentication/SKILL.md

jwt-authentication

Skill35 starsChanged 5 months agoMITView on GitHub

Use when implementing stateless JWT authentication in Symfony with LexikJWTAuthenticationBundle — login endpoint, token issuance, refresh tokens, custom claims, and the JWT firewall. Use when the task mentions JWT, bearer token, access token, refresh token, or stateless API auth.

  • Reads credentials

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Add claims (e.g. roles, tenant) at issuance via an event listener.

    line 90
  2. Distinctive:

    Keep the payload small and never put secrets or sensitive data in a JWT — it's base64, not encrypted.

    line 109
  3. Distinctive:

    Always serve over HTTPS — a bearer token on plain HTTP is a credential in the clear.

    line 123
  4. Very specific:

    Don't store JWTs in localStorage for browser SPAs if you can use an HttpOnly cookie (XSS risk).

    line 126

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

JWT Authentication (Symfony + LexikJWTAuthenticationBundle)

Setup

composer require lexik/jwt-authentication-bundle
php bin/console lexik:jwt:generate-keypair        # writes config/jwt/private.pem + public.pem
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 900                                # 15 min access token — keep it SHORT

The private key and passphrase are secrets — store them in the Symfony secrets vault, never commit them.

Firewall

# config/packages/security.yaml
security:
    firewalls:
        login:
            pattern: ^/api/login$
            stateless: true
            json_login:
                check_path: /api/login          # POST { "email": "...", "password": "..." }
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern: ^/api
            stateless: true
            jwt: ~                               # validates the Bearer token

    access_control:
        - { path: ^/api/login,   roles: PUBLIC_ACCESS }
        - { path: ^/api/token/refresh, roles: PUBLIC_ACCESS }
        - { path: ^/api,         roles: ROLE_USER }

json_login + the success handler issues the token; the jwt authenticator validates it on every subsequent request. Both firewalls are stateless — no session.

Login response

POST /api/login with credentials returns:

{ "token": "eyJ0eXAiOiJKV1Qi..." }

The client sends it back as Authorization: Bearer <token> on protected requests.

Short access tokens + refresh tokens

Access tokens must be short-lived (5–15 min) because they can't be revoked before expiry. For "stay logged in", pair them with a refresh token (gesdinet/jwt-refresh-token-bundle):

# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
    refresh_token_class: App\Entity\RefreshToken
    ttl: 2592000                                 # 30 days
    single_use: true                             # rotate on each refresh
# routes
api_refresh_token:
    path: /api/token/refresh
    controller: gesdinet.jwtrefreshtoken::refresh

Refresh tokens are stored server-side, so they can be revoked (logout, password change, compromise). single_use: true rotates them, limiting replay.

Custom claims

Add claims (e.g. roles, tenant) at issuance via an event listener; read them later without a DB hit.

#[AsEventListener(event: Events::JWT_CREATED)]
final class JwtCreatedListener
{
    public function __invoke(JWTCreatedEvent $event): void
    {
        $user = $event->getUser();
        if ($user instanceof User) {
            $payload = $event->getData();
            $payload['uid'] = $user->getId()->toRfc4122();
            $payload['tenant'] = $user->getTenantId();
            $event->setData($payload);
        }
    }
}

Keep the payload small and never put secrets or sensitive data in a JWT — it's base64, not encrypted; anyone holding it can read it.

Reading the authenticated user

#[Route('/api/me', methods: ['GET'])]
public function me(#[CurrentUser] User $user): JsonResponse
{
    return $this->json(UserResponse::fromEntity($user));
}

Security conventions

  • Always serve over HTTPS — a bearer token on plain HTTP is a credential in the clear.
  • Short access TTL + rotating refresh tokens; revoke refresh tokens on logout/password change.
  • Validate exp/iat (Lexik does this) — don't disable signature/expiry checks.
  • Don't store JWTs in localStorage for browser SPAs if you can use an HttpOnly cookie (XSS risk).
  • 401 on missing/expired/invalid token; 403 on valid token lacking the role.

Gotchas

  • Agent sets a long access-token TTL (hours/days) — keep access tokens short; use refresh tokens for longevity.
  • Agent uses only access tokens and tries to "revoke" them — access JWTs can't be revoked before expiry; use server-side refresh tokens.
  • Agent puts sensitive data in the JWT payload — it's readable; only put non-secret claims.
  • Agent commits private.pem / the passphrase — store keys/passphrase in the secrets vault.
  • Agent makes the JWT firewall stateful — set stateless: true.
  • Agent reads the user from a request field instead of #[CurrentUser] / the token.
  • Agent disables expiry or signature validation to "make it work" — never bypass token validation.
  • Agent uses Lexik 2.x annotation/config — use the current bundle config + json_login (Symfony 7).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.